Move the job scheduler service code to its own jar file.

- Also remove the dependency from SystemServiceRegistry to JobScheduler

See apex/jobscheduler/README_js-mainline.md for the details.

Bug: 137763703
Test: build and boot
Test: atest CtsJobSchedulerTestCases
Change-Id: I2386c78b7a6085d6e543a63f22cb620c4cabd06a
diff --git a/apex/jobscheduler/README_js-mainline.md b/apex/jobscheduler/README_js-mainline.md
new file mode 100644
index 0000000..b5fea5e
--- /dev/null
+++ b/apex/jobscheduler/README_js-mainline.md
@@ -0,0 +1,51 @@
+# Making Job Scheduler into a Mainline Module
+
+## TODOs
+
+See also:
+- http://go/moving-js-code-for-mainline
+- http://go/jobscheduler-code-dependencies-2019-07
+
+- [ ] Move client code
+  - [ ] Move code
+  - [ ] Make build file
+  - [ ] "m jobscheduler-framework" pass
+  - [ ] "m framework" pass
+  - [ ] "m service" pass
+- [ ] Move proto
+  - No, couldn't do it, because it's referred to by incidentd_proto
+- [ ] Move service
+  - [X] Move code (done, but it won't compile yet)
+  - [X] Make build file
+  - [X] "m service" pass
+  - [X] "m jobscheduler-service" pass
+    - To make it pass, jobscheduler-service has to link services.jar too. Many dependencies.
+- [ ] Move this into `frameworks/apex/jobscheduler/...`. Currently it's in `frameworks/base/apex/...`
+because `frameworks/apex/` is not a part of any git projects. (and also working on multiple
+projects is a pain.)
+
+
+## Problems
+- Couldn't move dumpsys proto files. They are used by incidentd_proto, which is in the platform
+  (not updatable).
+  - One idea is *not* to move the proto files into apex but keep them in the platform.
+    Then we make sure to extend the proto files in a backward-compat way (which we do anyway)
+    and always use the latest file from the JS apex.
+
+- There are a lot of build tasks that use "framework.jar". (Examples: hiddenapi-greylist.txt check,
+  update-api / public API check and SDK stub (android.jar) creation)
+  To make the downstream build modules buildable, we need to include js-framework.jar in
+  framework.jar. However it turned out to be tricky because soong has special logic for "framework"
+  and "framework.jar".
+  i.e. Conceptually, we can do it by renaming `framework` to `framework-minus-jobscheduler`, build
+  `jobscheduler-framework` with `framework-minus-jobscheduler`, and create `framework` by merging
+  `framework-minus-jobscheduler` and `jobscheduler-framework`.
+  However it didn't quite work because of the special casing.
+
+- JS-service uses a lot of other code in `services`, so it needs to link services.core.jar e.g.
+ - Common system service code, e.g. `com.android.server.SystemService`
+ - Common utility code, e.g. `FgThread` and `IoThread`
+ - Other system services such as `DeviceIdleController` and `ActivityManagerService`
+ - Server side singleton. `AppStateTracker`
+ - `DeviceIdleController.LocalService`, which is a local service but there's no interface class.
+ - `XxxInternal` interfaces that are not in the framework side. -> We should be able to move them.
diff --git a/apex/jobscheduler/service/Android.bp b/apex/jobscheduler/service/Android.bp
new file mode 100644
index 0000000..ca6dc45
--- /dev/null
+++ b/apex/jobscheduler/service/Android.bp
@@ -0,0 +1,15 @@
+// Job Scheduler Service jar, which will eventually be put in the jobscheduler mainline apex.
+// jobscheduler-service needs to be added to PRODUCT_SYSTEM_SERVER_JARS.
+java_library {
+    name: "jobscheduler-service",
+    installable: true,
+
+    srcs: [
+        "java/**/*.java",
+    ],
+
+    libs: [
+        "framework",
+        "services.core",
+    ],
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/GrantedUriPermissions.java b/apex/jobscheduler/service/java/com/android/server/job/GrantedUriPermissions.java
new file mode 100644
index 0000000..005b189
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/GrantedUriPermissions.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2017 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 android.app.IActivityManager;
+import android.app.UriGrantsManager;
+import android.content.ClipData;
+import android.content.ContentProvider;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.util.Slog;
+import android.util.proto.ProtoOutputStream;
+import com.android.server.LocalServices;
+import com.android.server.uri.UriGrantsManagerInternal;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+
+public final class GrantedUriPermissions {
+    private final int mGrantFlags;
+    private final int mSourceUserId;
+    private final String mTag;
+    private final IBinder mPermissionOwner;
+    private final ArrayList<Uri> mUris = new ArrayList<>();
+
+    private GrantedUriPermissions(IActivityManager am, int grantFlags, int uid, String tag)
+            throws RemoteException {
+        mGrantFlags = grantFlags;
+        mSourceUserId = UserHandle.getUserId(uid);
+        mTag = tag;
+        mPermissionOwner = LocalServices
+                .getService(UriGrantsManagerInternal.class).newUriPermissionOwner("job: " + tag);
+    }
+
+    public void revoke(IActivityManager am) {
+        for (int i = mUris.size()-1; i >= 0; i--) {
+            LocalServices.getService(UriGrantsManagerInternal.class).revokeUriPermissionFromOwner(
+                    mPermissionOwner, mUris.get(i), mGrantFlags, mSourceUserId);
+        }
+        mUris.clear();
+    }
+
+    public static boolean checkGrantFlags(int grantFlags) {
+        return (grantFlags & (Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+                |Intent.FLAG_GRANT_READ_URI_PERMISSION)) != 0;
+    }
+
+    public static GrantedUriPermissions createFromIntent(IActivityManager am, Intent intent,
+            int sourceUid, String targetPackage, int targetUserId, String tag) {
+        int grantFlags = intent.getFlags();
+        if (!checkGrantFlags(grantFlags)) {
+            return null;
+        }
+
+        GrantedUriPermissions perms = null;
+
+        Uri data = intent.getData();
+        if (data != null) {
+            perms = grantUri(am, data, sourceUid, targetPackage, targetUserId, grantFlags, tag,
+                    perms);
+        }
+
+        ClipData clip = intent.getClipData();
+        if (clip != null) {
+            perms = grantClip(am, clip, sourceUid, targetPackage, targetUserId, grantFlags, tag,
+                    perms);
+        }
+
+        return perms;
+    }
+
+    public static GrantedUriPermissions createFromClip(IActivityManager am, ClipData clip,
+            int sourceUid, String targetPackage, int targetUserId, int grantFlags, String tag) {
+        if (!checkGrantFlags(grantFlags)) {
+            return null;
+        }
+        GrantedUriPermissions perms = null;
+        if (clip != null) {
+            perms = grantClip(am, clip, sourceUid, targetPackage, targetUserId, grantFlags,
+                    tag, perms);
+        }
+        return perms;
+    }
+
+    private static GrantedUriPermissions grantClip(IActivityManager am, ClipData clip,
+            int sourceUid, String targetPackage, int targetUserId, int grantFlags, String tag,
+            GrantedUriPermissions curPerms) {
+        final int N = clip.getItemCount();
+        for (int i = 0; i < N; i++) {
+            curPerms = grantItem(am, clip.getItemAt(i), sourceUid, targetPackage, targetUserId,
+                    grantFlags, tag, curPerms);
+        }
+        return curPerms;
+    }
+
+    private static GrantedUriPermissions grantUri(IActivityManager am, Uri uri,
+            int sourceUid, String targetPackage, int targetUserId, int grantFlags, String tag,
+            GrantedUriPermissions curPerms) {
+        try {
+            int sourceUserId = ContentProvider.getUserIdFromUri(uri,
+                    UserHandle.getUserId(sourceUid));
+            uri = ContentProvider.getUriWithoutUserId(uri);
+            if (curPerms == null) {
+                curPerms = new GrantedUriPermissions(am, grantFlags, sourceUid, tag);
+            }
+            UriGrantsManager.getService().grantUriPermissionFromOwner(curPerms.mPermissionOwner,
+                    sourceUid, targetPackage, uri, grantFlags, sourceUserId, targetUserId);
+            curPerms.mUris.add(uri);
+        } catch (RemoteException e) {
+            Slog.e("JobScheduler", "AM dead");
+        }
+        return curPerms;
+    }
+
+    private static GrantedUriPermissions grantItem(IActivityManager am, ClipData.Item item,
+            int sourceUid, String targetPackage, int targetUserId, int grantFlags, String tag,
+            GrantedUriPermissions curPerms) {
+        if (item.getUri() != null) {
+            curPerms = grantUri(am, item.getUri(), sourceUid, targetPackage, targetUserId,
+                    grantFlags, tag, curPerms);
+        }
+        Intent intent = item.getIntent();
+        if (intent != null && intent.getData() != null) {
+            curPerms = grantUri(am, intent.getData(), sourceUid, targetPackage, targetUserId,
+                    grantFlags, tag, curPerms);
+        }
+        return curPerms;
+    }
+
+    // Dumpsys infrastructure
+    public void dump(PrintWriter pw, String prefix) {
+        pw.print(prefix); pw.print("mGrantFlags=0x"); pw.print(Integer.toHexString(mGrantFlags));
+        pw.print(" mSourceUserId="); pw.println(mSourceUserId);
+        pw.print(prefix); pw.print("mTag="); pw.println(mTag);
+        pw.print(prefix); pw.print("mPermissionOwner="); pw.println(mPermissionOwner);
+        for (int i = 0; i < mUris.size(); i++) {
+            pw.print(prefix); pw.print("#"); pw.print(i); pw.print(": ");
+            pw.println(mUris.get(i));
+        }
+    }
+
+    public void dump(ProtoOutputStream proto, long fieldId) {
+        final long token = proto.start(fieldId);
+
+        proto.write(GrantedUriPermissionsDumpProto.FLAGS, mGrantFlags);
+        proto.write(GrantedUriPermissionsDumpProto.SOURCE_USER_ID, mSourceUserId);
+        proto.write(GrantedUriPermissionsDumpProto.TAG, mTag);
+        proto.write(GrantedUriPermissionsDumpProto.PERMISSION_OWNER, mPermissionOwner.toString());
+        for (int i = 0; i < mUris.size(); i++) {
+            Uri u = mUris.get(i);
+            if (u != null) {
+                proto.write(GrantedUriPermissionsDumpProto.URIS, u.toString());
+            }
+        }
+
+        proto.end(token);
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobCompletedListener.java b/apex/jobscheduler/service/java/com/android/server/job/JobCompletedListener.java
new file mode 100644
index 0000000..34ba753b3
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/JobCompletedListener.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2014 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 com.android.server.job.controllers.JobStatus;
+
+/**
+ * Used for communication between {@link com.android.server.job.JobServiceContext} and the
+ * {@link com.android.server.job.JobSchedulerService}.
+ */
+public interface JobCompletedListener {
+    /**
+     * Callback for when a job is completed.
+     * @param needsReschedule Whether the implementing class should reschedule this job.
+     */
+    void onJobCompletedLocked(JobStatus jobStatus, boolean needsReschedule);
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobConcurrencyManager.java b/apex/jobscheduler/service/java/com/android/server/job/JobConcurrencyManager.java
new file mode 100644
index 0000000..bec1947
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/JobConcurrencyManager.java
@@ -0,0 +1,722 @@
+/*
+ * Copyright (C) 2018 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 android.app.ActivityManager;
+import android.app.job.JobInfo;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Handler;
+import android.os.PowerManager;
+import android.os.RemoteException;
+import android.util.Slog;
+import android.util.TimeUtils;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.app.procstats.ProcessStats;
+import com.android.internal.os.BackgroundThread;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.internal.util.StatLogger;
+import com.android.server.job.JobSchedulerService.Constants;
+import com.android.server.job.JobSchedulerService.MaxJobCountsPerMemoryTrimLevel;
+import com.android.server.job.controllers.JobStatus;
+import com.android.server.job.controllers.StateController;
+
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * This class decides, given the various configuration and the system status, how many more jobs
+ * can start.
+ */
+class JobConcurrencyManager {
+    private static final String TAG = JobSchedulerService.TAG;
+    private static final boolean DEBUG = JobSchedulerService.DEBUG;
+
+    private final Object mLock;
+    private final JobSchedulerService mService;
+    private final JobSchedulerService.Constants mConstants;
+    private final Context mContext;
+    private final Handler mHandler;
+
+    private PowerManager mPowerManager;
+
+    private boolean mCurrentInteractiveState;
+    private boolean mEffectiveInteractiveState;
+
+    private long mLastScreenOnRealtime;
+    private long mLastScreenOffRealtime;
+
+    private static final int MAX_JOB_CONTEXTS_COUNT = JobSchedulerService.MAX_JOB_CONTEXTS_COUNT;
+
+    /**
+     * This array essentially stores the state of mActiveServices array.
+     * The ith index stores the job present on the ith JobServiceContext.
+     * We manipulate this array until we arrive at what jobs should be running on
+     * what JobServiceContext.
+     */
+    JobStatus[] mRecycledAssignContextIdToJobMap = new JobStatus[MAX_JOB_CONTEXTS_COUNT];
+
+    boolean[] mRecycledSlotChanged = new boolean[MAX_JOB_CONTEXTS_COUNT];
+
+    int[] mRecycledPreferredUidForContext = new int[MAX_JOB_CONTEXTS_COUNT];
+
+    /** Max job counts according to the current system state. */
+    private JobSchedulerService.MaxJobCounts mMaxJobCounts;
+
+    private final JobCountTracker mJobCountTracker = new JobCountTracker();
+
+    /** Current memory trim level. */
+    private int mLastMemoryTrimLevel;
+
+    /** Used to throttle heavy API calls. */
+    private long mNextSystemStateRefreshTime;
+    private static final int SYSTEM_STATE_REFRESH_MIN_INTERVAL = 1000;
+
+    private final StatLogger mStatLogger = new StatLogger(new String[]{
+            "assignJobsToContexts",
+            "refreshSystemState",
+    });
+
+    interface Stats {
+        int ASSIGN_JOBS_TO_CONTEXTS = 0;
+        int REFRESH_SYSTEM_STATE = 1;
+
+        int COUNT = REFRESH_SYSTEM_STATE + 1;
+    }
+
+    JobConcurrencyManager(JobSchedulerService service) {
+        mService = service;
+        mLock = mService.mLock;
+        mConstants = service.mConstants;
+        mContext = service.getContext();
+
+        mHandler = BackgroundThread.getHandler();
+    }
+
+    public void onSystemReady() {
+        mPowerManager = mContext.getSystemService(PowerManager.class);
+
+        final IntentFilter filter = new IntentFilter(Intent.ACTION_SCREEN_ON);
+        filter.addAction(Intent.ACTION_SCREEN_OFF);
+        mContext.registerReceiver(mReceiver, filter);
+
+        onInteractiveStateChanged(mPowerManager.isInteractive());
+    }
+
+    private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            switch (intent.getAction()) {
+                case Intent.ACTION_SCREEN_ON:
+                    onInteractiveStateChanged(true);
+                    break;
+                case Intent.ACTION_SCREEN_OFF:
+                    onInteractiveStateChanged(false);
+                    break;
+            }
+        }
+    };
+
+    /**
+     * Called when the screen turns on / off.
+     */
+    private void onInteractiveStateChanged(boolean interactive) {
+        synchronized (mLock) {
+            if (mCurrentInteractiveState == interactive) {
+                return;
+            }
+            mCurrentInteractiveState = interactive;
+            if (DEBUG) {
+                Slog.d(TAG, "Interactive: " + interactive);
+            }
+
+            final long nowRealtime = JobSchedulerService.sElapsedRealtimeClock.millis();
+            if (interactive) {
+                mLastScreenOnRealtime = nowRealtime;
+                mEffectiveInteractiveState = true;
+
+                mHandler.removeCallbacks(mRampUpForScreenOff);
+            } else {
+                mLastScreenOffRealtime = nowRealtime;
+
+                // Set mEffectiveInteractiveState to false after the delay, when we may increase
+                // the concurrency.
+                // We don't need a wakeup alarm here. When there's a pending job, there should
+                // also be jobs running too, meaning the device should be awake.
+
+                // Note: we can't directly do postDelayed(this::rampUpForScreenOn), because
+                // we need the exact same instance for removeCallbacks().
+                mHandler.postDelayed(mRampUpForScreenOff,
+                        mConstants.SCREEN_OFF_JOB_CONCURRENCY_INCREASE_DELAY_MS.getValue());
+            }
+        }
+    }
+
+    private final Runnable mRampUpForScreenOff = this::rampUpForScreenOff;
+
+    /**
+     * Called in {@link Constants#SCREEN_OFF_JOB_CONCURRENCY_INCREASE_DELAY_MS} after
+     * the screen turns off, in order to increase concurrency.
+     */
+    private void rampUpForScreenOff() {
+        synchronized (mLock) {
+            // Make sure the screen has really been off for the configured duration.
+            // (There could be a race.)
+            if (!mEffectiveInteractiveState) {
+                return;
+            }
+            if (mLastScreenOnRealtime > mLastScreenOffRealtime) {
+                return;
+            }
+            final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+            if ((mLastScreenOffRealtime
+                    + mConstants.SCREEN_OFF_JOB_CONCURRENCY_INCREASE_DELAY_MS.getValue())
+                    > now) {
+                return;
+            }
+
+            mEffectiveInteractiveState = false;
+
+            if (DEBUG) {
+                Slog.d(TAG, "Ramping up concurrency");
+            }
+
+            mService.maybeRunPendingJobsLocked();
+        }
+    }
+
+    private boolean isFgJob(JobStatus job) {
+        return job.lastEvaluatedPriority >= JobInfo.PRIORITY_TOP_APP;
+    }
+
+    @GuardedBy("mLock")
+    private void refreshSystemStateLocked() {
+        final long nowUptime = JobSchedulerService.sUptimeMillisClock.millis();
+
+        // Only refresh the information every so often.
+        if (nowUptime < mNextSystemStateRefreshTime) {
+            return;
+        }
+
+        final long start = mStatLogger.getTime();
+        mNextSystemStateRefreshTime = nowUptime + SYSTEM_STATE_REFRESH_MIN_INTERVAL;
+
+        mLastMemoryTrimLevel = ProcessStats.ADJ_MEM_FACTOR_NORMAL;
+        try {
+            mLastMemoryTrimLevel = ActivityManager.getService().getMemoryTrimLevel();
+        } catch (RemoteException e) {
+        }
+
+        mStatLogger.logDurationStat(Stats.REFRESH_SYSTEM_STATE, start);
+    }
+
+    @GuardedBy("mLock")
+    private void updateMaxCountsLocked() {
+        refreshSystemStateLocked();
+
+        final MaxJobCountsPerMemoryTrimLevel jobCounts = mEffectiveInteractiveState
+                ? mConstants.MAX_JOB_COUNTS_SCREEN_ON
+                : mConstants.MAX_JOB_COUNTS_SCREEN_OFF;
+
+
+        switch (mLastMemoryTrimLevel) {
+            case ProcessStats.ADJ_MEM_FACTOR_MODERATE:
+                mMaxJobCounts = jobCounts.moderate;
+                break;
+            case ProcessStats.ADJ_MEM_FACTOR_LOW:
+                mMaxJobCounts = jobCounts.low;
+                break;
+            case ProcessStats.ADJ_MEM_FACTOR_CRITICAL:
+                mMaxJobCounts = jobCounts.critical;
+                break;
+            default:
+                mMaxJobCounts = jobCounts.normal;
+                break;
+        }
+    }
+
+    /**
+     * Takes jobs from pending queue and runs them on available contexts.
+     * If no contexts are available, preempts lower priority jobs to
+     * run higher priority ones.
+     * Lock on mJobs before calling this function.
+     */
+    @GuardedBy("mLock")
+    void assignJobsToContextsLocked() {
+        final long start = mStatLogger.getTime();
+
+        assignJobsToContextsInternalLocked();
+
+        mStatLogger.logDurationStat(Stats.ASSIGN_JOBS_TO_CONTEXTS, start);
+    }
+
+    @GuardedBy("mLock")
+    private void assignJobsToContextsInternalLocked() {
+        if (DEBUG) {
+            Slog.d(TAG, printPendingQueueLocked());
+        }
+
+        final JobPackageTracker tracker = mService.mJobPackageTracker;
+        final List<JobStatus> pendingJobs = mService.mPendingJobs;
+        final List<JobServiceContext> activeServices = mService.mActiveServices;
+        final List<StateController> controllers = mService.mControllers;
+
+        updateMaxCountsLocked();
+
+        // To avoid GC churn, we recycle the arrays.
+        JobStatus[] contextIdToJobMap = mRecycledAssignContextIdToJobMap;
+        boolean[] slotChanged = mRecycledSlotChanged;
+        int[] preferredUidForContext = mRecycledPreferredUidForContext;
+
+
+        // Initialize the work variables and also count running jobs.
+        mJobCountTracker.reset(
+                mMaxJobCounts.getMaxTotal(),
+                mMaxJobCounts.getMaxBg(),
+                mMaxJobCounts.getMinBg());
+
+        for (int i=0; i<MAX_JOB_CONTEXTS_COUNT; i++) {
+            final JobServiceContext js = mService.mActiveServices.get(i);
+            final JobStatus status = js.getRunningJobLocked();
+
+            if ((contextIdToJobMap[i] = status) != null) {
+                mJobCountTracker.incrementRunningJobCount(isFgJob(status));
+            }
+
+            slotChanged[i] = false;
+            preferredUidForContext[i] = js.getPreferredUid();
+        }
+        if (DEBUG) {
+            Slog.d(TAG, printContextIdToJobMap(contextIdToJobMap, "running jobs initial"));
+        }
+
+        // Next, update the job priorities, and also count the pending FG / BG jobs.
+        for (int i = 0; i < pendingJobs.size(); i++) {
+            final JobStatus pending = pendingJobs.get(i);
+
+            // If job is already running, go to next job.
+            int jobRunningContext = findJobContextIdFromMap(pending, contextIdToJobMap);
+            if (jobRunningContext != -1) {
+                continue;
+            }
+
+            final int priority = mService.evaluateJobPriorityLocked(pending);
+            pending.lastEvaluatedPriority = priority;
+
+            mJobCountTracker.incrementPendingJobCount(isFgJob(pending));
+        }
+
+        mJobCountTracker.onCountDone();
+
+        for (int i = 0; i < pendingJobs.size(); i++) {
+            final JobStatus nextPending = pendingJobs.get(i);
+
+            // Unfortunately we need to repeat this relatively expensive check.
+            int jobRunningContext = findJobContextIdFromMap(nextPending, contextIdToJobMap);
+            if (jobRunningContext != -1) {
+                continue;
+            }
+
+            final boolean isPendingFg = isFgJob(nextPending);
+
+            // Find an available slot for nextPending. The context should be available OR
+            // it should have lowest priority among all running jobs
+            // (sharing the same Uid as nextPending)
+            int minPriorityForPreemption = Integer.MAX_VALUE;
+            int selectedContextId = -1;
+            boolean startingJob = false;
+            for (int j=0; j<MAX_JOB_CONTEXTS_COUNT; j++) {
+                JobStatus job = contextIdToJobMap[j];
+                int preferredUid = preferredUidForContext[j];
+                if (job == null) {
+                    final boolean preferredUidOkay = (preferredUid == nextPending.getUid())
+                            || (preferredUid == JobServiceContext.NO_PREFERRED_UID);
+
+                    if (preferredUidOkay && mJobCountTracker.canJobStart(isPendingFg)) {
+                        // This slot is free, and we haven't yet hit the limit on
+                        // concurrent jobs...  we can just throw the job in to here.
+                        selectedContextId = j;
+                        startingJob = true;
+                        break;
+                    }
+                    // No job on this context, but nextPending can't run here because
+                    // the context has a preferred Uid or we have reached the limit on
+                    // concurrent jobs.
+                    continue;
+                }
+                if (job.getUid() != nextPending.getUid()) {
+                    continue;
+                }
+
+                final int jobPriority = mService.evaluateJobPriorityLocked(job);
+                if (jobPriority >= nextPending.lastEvaluatedPriority) {
+                    continue;
+                }
+
+                // TODO lastEvaluatedPriority should be evaluateJobPriorityLocked. (double check it)
+                if (minPriorityForPreemption > nextPending.lastEvaluatedPriority) {
+                    minPriorityForPreemption = nextPending.lastEvaluatedPriority;
+                    selectedContextId = j;
+                    // In this case, we're just going to preempt a low priority job, we're not
+                    // actually starting a job, so don't set startingJob.
+                }
+            }
+            if (selectedContextId != -1) {
+                contextIdToJobMap[selectedContextId] = nextPending;
+                slotChanged[selectedContextId] = true;
+            }
+            if (startingJob) {
+                // Increase the counters when we're going to start a job.
+                mJobCountTracker.onStartingNewJob(isPendingFg);
+            }
+        }
+        if (DEBUG) {
+            Slog.d(TAG, printContextIdToJobMap(contextIdToJobMap, "running jobs final"));
+        }
+
+        mJobCountTracker.logStatus();
+
+        tracker.noteConcurrency(mJobCountTracker.getTotalRunningJobCountToNote(),
+                mJobCountTracker.getFgRunningJobCountToNote());
+
+        for (int i=0; i<MAX_JOB_CONTEXTS_COUNT; i++) {
+            boolean preservePreferredUid = false;
+            if (slotChanged[i]) {
+                JobStatus js = activeServices.get(i).getRunningJobLocked();
+                if (js != null) {
+                    if (DEBUG) {
+                        Slog.d(TAG, "preempting job: "
+                                + activeServices.get(i).getRunningJobLocked());
+                    }
+                    // preferredUid will be set to uid of currently running job.
+                    activeServices.get(i).preemptExecutingJobLocked();
+                    preservePreferredUid = true;
+                } else {
+                    final JobStatus pendingJob = contextIdToJobMap[i];
+                    if (DEBUG) {
+                        Slog.d(TAG, "About to run job on context "
+                                + i + ", job: " + pendingJob);
+                    }
+                    for (int ic=0; ic<controllers.size(); ic++) {
+                        controllers.get(ic).prepareForExecutionLocked(pendingJob);
+                    }
+                    if (!activeServices.get(i).executeRunnableJob(pendingJob)) {
+                        Slog.d(TAG, "Error executing " + pendingJob);
+                    }
+                    if (pendingJobs.remove(pendingJob)) {
+                        tracker.noteNonpending(pendingJob);
+                    }
+                }
+            }
+            if (!preservePreferredUid) {
+                activeServices.get(i).clearPreferredUid();
+            }
+        }
+    }
+
+    private static int findJobContextIdFromMap(JobStatus jobStatus, JobStatus[] map) {
+        for (int i=0; i<map.length; i++) {
+            if (map[i] != null && map[i].matches(jobStatus.getUid(), jobStatus.getJobId())) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    @GuardedBy("mLock")
+    private String printPendingQueueLocked() {
+        StringBuilder s = new StringBuilder("Pending queue: ");
+        Iterator<JobStatus> it = mService.mPendingJobs.iterator();
+        while (it.hasNext()) {
+            JobStatus js = it.next();
+            s.append("(")
+                    .append(js.getJob().getId())
+                    .append(", ")
+                    .append(js.getUid())
+                    .append(") ");
+        }
+        return s.toString();
+    }
+
+    private static String printContextIdToJobMap(JobStatus[] map, String initial) {
+        StringBuilder s = new StringBuilder(initial + ": ");
+        for (int i=0; i<map.length; i++) {
+            s.append("(")
+                    .append(map[i] == null? -1: map[i].getJobId())
+                    .append(map[i] == null? -1: map[i].getUid())
+                    .append(")" );
+        }
+        return s.toString();
+    }
+
+
+    public void dumpLocked(IndentingPrintWriter pw, long now, long nowRealtime) {
+        pw.println("Concurrency:");
+
+        pw.increaseIndent();
+        try {
+            pw.print("Screen state: current ");
+            pw.print(mCurrentInteractiveState ? "ON" : "OFF");
+            pw.print("  effective ");
+            pw.print(mEffectiveInteractiveState ? "ON" : "OFF");
+            pw.println();
+
+            pw.print("Last screen ON : ");
+            TimeUtils.dumpTimeWithDelta(pw, now - nowRealtime + mLastScreenOnRealtime, now);
+            pw.println();
+
+            pw.print("Last screen OFF: ");
+            TimeUtils.dumpTimeWithDelta(pw, now - nowRealtime + mLastScreenOffRealtime, now);
+            pw.println();
+
+            pw.println();
+
+            pw.println("Current max jobs:");
+            pw.println("  ");
+            pw.println(mJobCountTracker);
+
+            pw.println();
+
+            pw.print("mLastMemoryTrimLevel: ");
+            pw.print(mLastMemoryTrimLevel);
+            pw.println();
+
+            mStatLogger.dump(pw);
+        } finally {
+            pw.decreaseIndent();
+        }
+    }
+
+    public void dumpProtoLocked(ProtoOutputStream proto, long tag, long now, long nowRealtime) {
+        final long token = proto.start(tag);
+
+        proto.write(JobConcurrencyManagerProto.CURRENT_INTERACTIVE,
+                mCurrentInteractiveState);
+        proto.write(JobConcurrencyManagerProto.EFFECTIVE_INTERACTIVE,
+                mEffectiveInteractiveState);
+
+        proto.write(JobConcurrencyManagerProto.TIME_SINCE_LAST_SCREEN_ON_MS,
+                nowRealtime - mLastScreenOnRealtime);
+        proto.write(JobConcurrencyManagerProto.TIME_SINCE_LAST_SCREEN_OFF_MS,
+                nowRealtime - mLastScreenOffRealtime);
+
+        mJobCountTracker.dumpProto(proto, JobConcurrencyManagerProto.JOB_COUNT_TRACKER);
+
+        proto.write(JobConcurrencyManagerProto.MEMORY_TRIM_LEVEL,
+                mLastMemoryTrimLevel);
+
+        proto.end(token);
+    }
+
+    /**
+     * This class decides, taking into account {@link #mMaxJobCounts} and how mny jos are running /
+     * pending, how many more job can start.
+     *
+     * Extracted for testing and logging.
+     */
+    @VisibleForTesting
+    static class JobCountTracker {
+        private int mConfigNumMaxTotalJobs;
+        private int mConfigNumMaxBgJobs;
+        private int mConfigNumMinBgJobs;
+
+        private int mNumRunningFgJobs;
+        private int mNumRunningBgJobs;
+
+        private int mNumPendingFgJobs;
+        private int mNumPendingBgJobs;
+
+        private int mNumStartingFgJobs;
+        private int mNumStartingBgJobs;
+
+        private int mNumReservedForBg;
+        private int mNumActualMaxFgJobs;
+        private int mNumActualMaxBgJobs;
+
+        void reset(int numTotalMaxJobs, int numMaxBgJobs, int numMinBgJobs) {
+            mConfigNumMaxTotalJobs = numTotalMaxJobs;
+            mConfigNumMaxBgJobs = numMaxBgJobs;
+            mConfigNumMinBgJobs = numMinBgJobs;
+
+            mNumRunningFgJobs = 0;
+            mNumRunningBgJobs = 0;
+
+            mNumPendingFgJobs = 0;
+            mNumPendingBgJobs = 0;
+
+            mNumStartingFgJobs = 0;
+            mNumStartingBgJobs = 0;
+
+            mNumReservedForBg = 0;
+            mNumActualMaxFgJobs = 0;
+            mNumActualMaxBgJobs = 0;
+        }
+
+        void incrementRunningJobCount(boolean isFg) {
+            if (isFg) {
+                mNumRunningFgJobs++;
+            } else {
+                mNumRunningBgJobs++;
+            }
+        }
+
+        void incrementPendingJobCount(boolean isFg) {
+            if (isFg) {
+                mNumPendingFgJobs++;
+            } else {
+                mNumPendingBgJobs++;
+            }
+        }
+
+        void onStartingNewJob(boolean isFg) {
+            if (isFg) {
+                mNumStartingFgJobs++;
+            } else {
+                mNumStartingBgJobs++;
+            }
+        }
+
+        void onCountDone() {
+            // Note some variables are used only here but are made class members in order to have
+            // them on logcat / dumpsys.
+
+            // How many slots should we allocate to BG jobs at least?
+            // That's basically "getMinBg()", but if there are less jobs, decrease it.
+            // (e.g. even if min-bg is 2, if there's only 1 running+pending job, this has to be 1.)
+            final int reservedForBg = Math.min(
+                    mConfigNumMinBgJobs,
+                    mNumRunningBgJobs + mNumPendingBgJobs);
+
+            // However, if there are FG jobs already running, we have to adjust it.
+            mNumReservedForBg = Math.min(reservedForBg,
+                    mConfigNumMaxTotalJobs - mNumRunningFgJobs);
+
+            // Max FG is [total - [number needed for BG jobs]]
+            // [number needed for BG jobs] is the bigger one of [running BG] or [reserved BG]
+            final int maxFg =
+                    mConfigNumMaxTotalJobs - Math.max(mNumRunningBgJobs, mNumReservedForBg);
+
+            // The above maxFg is the theoretical max. If there are less FG jobs, the actual
+            // max FG will be lower accordingly.
+            mNumActualMaxFgJobs = Math.min(
+                    maxFg,
+                    mNumRunningFgJobs + mNumPendingFgJobs);
+
+            // Max BG is [total - actual max FG], but cap at [config max BG].
+            final int maxBg = Math.min(
+                    mConfigNumMaxBgJobs,
+                    mConfigNumMaxTotalJobs - mNumActualMaxFgJobs);
+
+            // If there are less BG jobs than maxBg, then reduce the actual max BG accordingly.
+            // This isn't needed for the logic to work, but this will give consistent output
+            // on logcat and dumpsys.
+            mNumActualMaxBgJobs = Math.min(
+                    maxBg,
+                    mNumRunningBgJobs + mNumPendingBgJobs);
+        }
+
+        boolean canJobStart(boolean isFg) {
+            if (isFg) {
+                return mNumRunningFgJobs + mNumStartingFgJobs < mNumActualMaxFgJobs;
+            } else {
+                return mNumRunningBgJobs + mNumStartingBgJobs < mNumActualMaxBgJobs;
+            }
+        }
+
+        public int getNumStartingFgJobs() {
+            return mNumStartingFgJobs;
+        }
+
+        public int getNumStartingBgJobs() {
+            return mNumStartingBgJobs;
+        }
+
+        int getTotalRunningJobCountToNote() {
+            return mNumRunningFgJobs + mNumRunningBgJobs
+                    + mNumStartingFgJobs + mNumStartingBgJobs;
+        }
+
+        int getFgRunningJobCountToNote() {
+            return mNumRunningFgJobs + mNumStartingFgJobs;
+        }
+
+        void logStatus() {
+            if (DEBUG) {
+                Slog.d(TAG, "assignJobsToContexts: " + this);
+            }
+        }
+
+        public String toString() {
+            final int totalFg = mNumRunningFgJobs + mNumStartingFgJobs;
+            final int totalBg = mNumRunningBgJobs + mNumStartingBgJobs;
+            return String.format(
+                    "Config={tot=%d bg min/max=%d/%d}"
+                            + " Running[FG/BG (total)]: %d / %d (%d)"
+                            + " Pending: %d / %d (%d)"
+                            + " Actual max: %d%s / %d%s (%d%s)"
+                            + " Res BG: %d"
+                            + " Starting: %d / %d (%d)"
+                            + " Total: %d%s / %d%s (%d%s)",
+                    mConfigNumMaxTotalJobs,
+                    mConfigNumMinBgJobs,
+                    mConfigNumMaxBgJobs,
+
+                    mNumRunningFgJobs, mNumRunningBgJobs,
+                    mNumRunningFgJobs + mNumRunningBgJobs,
+
+                    mNumPendingFgJobs, mNumPendingBgJobs,
+                    mNumPendingFgJobs + mNumPendingBgJobs,
+
+                    mNumActualMaxFgJobs, (totalFg <= mConfigNumMaxTotalJobs) ? "" : "*",
+                    mNumActualMaxBgJobs, (totalBg <= mConfigNumMaxBgJobs) ? "" : "*",
+
+                    mNumActualMaxFgJobs + mNumActualMaxBgJobs,
+                    (mNumActualMaxFgJobs + mNumActualMaxBgJobs <= mConfigNumMaxTotalJobs)
+                            ? "" : "*",
+
+                    mNumReservedForBg,
+
+                    mNumStartingFgJobs, mNumStartingBgJobs, mNumStartingFgJobs + mNumStartingBgJobs,
+
+                    totalFg, (totalFg <= mNumActualMaxFgJobs) ? "" : "*",
+                    totalBg, (totalBg <= mNumActualMaxBgJobs) ? "" : "*",
+                    totalFg + totalBg, (totalFg + totalBg <= mConfigNumMaxTotalJobs) ? "" : "*"
+            );
+        }
+
+        public void dumpProto(ProtoOutputStream proto, long fieldId) {
+            final long token = proto.start(fieldId);
+
+            proto.write(JobCountTrackerProto.CONFIG_NUM_MAX_TOTAL_JOBS, mConfigNumMaxTotalJobs);
+            proto.write(JobCountTrackerProto.CONFIG_NUM_MAX_BG_JOBS, mConfigNumMaxBgJobs);
+            proto.write(JobCountTrackerProto.CONFIG_NUM_MIN_BG_JOBS, mConfigNumMinBgJobs);
+
+            proto.write(JobCountTrackerProto.NUM_RUNNING_FG_JOBS, mNumRunningFgJobs);
+            proto.write(JobCountTrackerProto.NUM_RUNNING_BG_JOBS, mNumRunningBgJobs);
+
+            proto.write(JobCountTrackerProto.NUM_PENDING_FG_JOBS, mNumPendingFgJobs);
+            proto.write(JobCountTrackerProto.NUM_PENDING_BG_JOBS, mNumPendingBgJobs);
+
+            proto.end(token);
+        }
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobPackageTracker.java b/apex/jobscheduler/service/java/com/android/server/job/JobPackageTracker.java
new file mode 100644
index 0000000..e28e5bd
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/JobPackageTracker.java
@@ -0,0 +1,653 @@
+/*
+ * Copyright (C) 2016 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.server.job.JobSchedulerService.sElapsedRealtimeClock;
+import static com.android.server.job.JobSchedulerService.sSystemClock;
+import static com.android.server.job.JobSchedulerService.sUptimeMillisClock;
+
+import android.app.job.JobInfo;
+import android.app.job.JobParameters;
+import android.os.UserHandle;
+import android.text.format.DateFormat;
+import android.util.ArrayMap;
+import android.util.SparseArray;
+import android.util.SparseIntArray;
+import android.util.TimeUtils;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.util.RingBufferIndices;
+import com.android.server.job.controllers.JobStatus;
+
+import java.io.PrintWriter;
+
+public final class JobPackageTracker {
+    // We batch every 30 minutes.
+    static final long BATCHING_TIME = 30*60*1000;
+    // Number of historical data sets we keep.
+    static final int NUM_HISTORY = 5;
+
+    private static final int EVENT_BUFFER_SIZE = 100;
+
+    public static final int EVENT_CMD_MASK = 0xff;
+    public static final int EVENT_STOP_REASON_SHIFT = 8;
+    public static final int EVENT_STOP_REASON_MASK = 0xff << EVENT_STOP_REASON_SHIFT;
+    public static final int EVENT_NULL = 0;
+    public static final int EVENT_START_JOB = 1;
+    public static final int EVENT_STOP_JOB = 2;
+    public static final int EVENT_START_PERIODIC_JOB = 3;
+    public static final int EVENT_STOP_PERIODIC_JOB = 4;
+
+    private final RingBufferIndices mEventIndices = new RingBufferIndices(EVENT_BUFFER_SIZE);
+    private final int[] mEventCmds = new int[EVENT_BUFFER_SIZE];
+    private final long[] mEventTimes = new long[EVENT_BUFFER_SIZE];
+    private final int[] mEventUids = new int[EVENT_BUFFER_SIZE];
+    private final String[] mEventTags = new String[EVENT_BUFFER_SIZE];
+    private final int[] mEventJobIds = new int[EVENT_BUFFER_SIZE];
+    private final String[] mEventReasons = new String[EVENT_BUFFER_SIZE];
+
+    public void addEvent(int cmd, int uid, String tag, int jobId, int stopReason,
+            String debugReason) {
+        int index = mEventIndices.add();
+        mEventCmds[index] = cmd | ((stopReason<<EVENT_STOP_REASON_SHIFT) & EVENT_STOP_REASON_MASK);
+        mEventTimes[index] = sElapsedRealtimeClock.millis();
+        mEventUids[index] = uid;
+        mEventTags[index] = tag;
+        mEventJobIds[index] = jobId;
+        mEventReasons[index] = debugReason;
+    }
+
+    DataSet mCurDataSet = new DataSet();
+    DataSet[] mLastDataSets = new DataSet[NUM_HISTORY];
+
+    final static class PackageEntry {
+        long pastActiveTime;
+        long activeStartTime;
+        int activeNesting;
+        int activeCount;
+        boolean hadActive;
+        long pastActiveTopTime;
+        long activeTopStartTime;
+        int activeTopNesting;
+        int activeTopCount;
+        boolean hadActiveTop;
+        long pastPendingTime;
+        long pendingStartTime;
+        int pendingNesting;
+        int pendingCount;
+        boolean hadPending;
+        final SparseIntArray stopReasons = new SparseIntArray();
+
+        public long getActiveTime(long now) {
+            long time = pastActiveTime;
+            if (activeNesting > 0) {
+                time += now - activeStartTime;
+            }
+            return time;
+        }
+
+        public long getActiveTopTime(long now) {
+            long time = pastActiveTopTime;
+            if (activeTopNesting > 0) {
+                time += now - activeTopStartTime;
+            }
+            return time;
+        }
+
+        public long getPendingTime(long now) {
+            long time = pastPendingTime;
+            if (pendingNesting > 0) {
+                time += now - pendingStartTime;
+            }
+            return time;
+        }
+    }
+
+    final static class DataSet {
+        final SparseArray<ArrayMap<String, PackageEntry>> mEntries = new SparseArray<>();
+        final long mStartUptimeTime;
+        final long mStartElapsedTime;
+        final long mStartClockTime;
+        long mSummedTime;
+        int mMaxTotalActive;
+        int mMaxFgActive;
+
+        public DataSet(DataSet otherTimes) {
+            mStartUptimeTime = otherTimes.mStartUptimeTime;
+            mStartElapsedTime = otherTimes.mStartElapsedTime;
+            mStartClockTime = otherTimes.mStartClockTime;
+        }
+
+        public DataSet() {
+            mStartUptimeTime = sUptimeMillisClock.millis();
+            mStartElapsedTime = sElapsedRealtimeClock.millis();
+            mStartClockTime = sSystemClock.millis();
+        }
+
+        private PackageEntry getOrCreateEntry(int uid, String pkg) {
+            ArrayMap<String, PackageEntry> uidMap = mEntries.get(uid);
+            if (uidMap == null) {
+                uidMap = new ArrayMap<>();
+                mEntries.put(uid, uidMap);
+            }
+            PackageEntry entry = uidMap.get(pkg);
+            if (entry == null) {
+                entry = new PackageEntry();
+                uidMap.put(pkg, entry);
+            }
+            return entry;
+        }
+
+        public PackageEntry getEntry(int uid, String pkg) {
+            ArrayMap<String, PackageEntry> uidMap = mEntries.get(uid);
+            if (uidMap == null) {
+                return null;
+            }
+            return uidMap.get(pkg);
+        }
+
+        long getTotalTime(long now) {
+            if (mSummedTime > 0) {
+                return mSummedTime;
+            }
+            return now - mStartUptimeTime;
+        }
+
+        void incPending(int uid, String pkg, long now) {
+            PackageEntry pe = getOrCreateEntry(uid, pkg);
+            if (pe.pendingNesting == 0) {
+                pe.pendingStartTime = now;
+                pe.pendingCount++;
+            }
+            pe.pendingNesting++;
+        }
+
+        void decPending(int uid, String pkg, long now) {
+            PackageEntry pe = getOrCreateEntry(uid, pkg);
+            if (pe.pendingNesting == 1) {
+                pe.pastPendingTime += now - pe.pendingStartTime;
+            }
+            pe.pendingNesting--;
+        }
+
+        void incActive(int uid, String pkg, long now) {
+            PackageEntry pe = getOrCreateEntry(uid, pkg);
+            if (pe.activeNesting == 0) {
+                pe.activeStartTime = now;
+                pe.activeCount++;
+            }
+            pe.activeNesting++;
+        }
+
+        void decActive(int uid, String pkg, long now, int stopReason) {
+            PackageEntry pe = getOrCreateEntry(uid, pkg);
+            if (pe.activeNesting == 1) {
+                pe.pastActiveTime += now - pe.activeStartTime;
+            }
+            pe.activeNesting--;
+            int count = pe.stopReasons.get(stopReason, 0);
+            pe.stopReasons.put(stopReason, count+1);
+        }
+
+        void incActiveTop(int uid, String pkg, long now) {
+            PackageEntry pe = getOrCreateEntry(uid, pkg);
+            if (pe.activeTopNesting == 0) {
+                pe.activeTopStartTime = now;
+                pe.activeTopCount++;
+            }
+            pe.activeTopNesting++;
+        }
+
+        void decActiveTop(int uid, String pkg, long now, int stopReason) {
+            PackageEntry pe = getOrCreateEntry(uid, pkg);
+            if (pe.activeTopNesting == 1) {
+                pe.pastActiveTopTime += now - pe.activeTopStartTime;
+            }
+            pe.activeTopNesting--;
+            int count = pe.stopReasons.get(stopReason, 0);
+            pe.stopReasons.put(stopReason, count+1);
+        }
+
+        void finish(DataSet next, long now) {
+            for (int i = mEntries.size() - 1; i >= 0; i--) {
+                ArrayMap<String, PackageEntry> uidMap = mEntries.valueAt(i);
+                for (int j = uidMap.size() - 1; j >= 0; j--) {
+                    PackageEntry pe = uidMap.valueAt(j);
+                    if (pe.activeNesting > 0 || pe.activeTopNesting > 0 || pe.pendingNesting > 0) {
+                        // Propagate existing activity in to next data set.
+                        PackageEntry nextPe = next.getOrCreateEntry(mEntries.keyAt(i), uidMap.keyAt(j));
+                        nextPe.activeStartTime = now;
+                        nextPe.activeNesting = pe.activeNesting;
+                        nextPe.activeTopStartTime = now;
+                        nextPe.activeTopNesting = pe.activeTopNesting;
+                        nextPe.pendingStartTime = now;
+                        nextPe.pendingNesting = pe.pendingNesting;
+                        // Finish it off.
+                        if (pe.activeNesting > 0) {
+                            pe.pastActiveTime += now - pe.activeStartTime;
+                            pe.activeNesting = 0;
+                        }
+                        if (pe.activeTopNesting > 0) {
+                            pe.pastActiveTopTime += now - pe.activeTopStartTime;
+                            pe.activeTopNesting = 0;
+                        }
+                        if (pe.pendingNesting > 0) {
+                            pe.pastPendingTime += now - pe.pendingStartTime;
+                            pe.pendingNesting = 0;
+                        }
+                    }
+                }
+            }
+        }
+
+        void addTo(DataSet out, long now) {
+            out.mSummedTime += getTotalTime(now);
+            for (int i = mEntries.size() - 1; i >= 0; i--) {
+                ArrayMap<String, PackageEntry> uidMap = mEntries.valueAt(i);
+                for (int j = uidMap.size() - 1; j >= 0; j--) {
+                    PackageEntry pe = uidMap.valueAt(j);
+                    PackageEntry outPe = out.getOrCreateEntry(mEntries.keyAt(i), uidMap.keyAt(j));
+                    outPe.pastActiveTime += pe.pastActiveTime;
+                    outPe.activeCount += pe.activeCount;
+                    outPe.pastActiveTopTime += pe.pastActiveTopTime;
+                    outPe.activeTopCount += pe.activeTopCount;
+                    outPe.pastPendingTime += pe.pastPendingTime;
+                    outPe.pendingCount += pe.pendingCount;
+                    if (pe.activeNesting > 0) {
+                        outPe.pastActiveTime += now - pe.activeStartTime;
+                        outPe.hadActive = true;
+                    }
+                    if (pe.activeTopNesting > 0) {
+                        outPe.pastActiveTopTime += now - pe.activeTopStartTime;
+                        outPe.hadActiveTop = true;
+                    }
+                    if (pe.pendingNesting > 0) {
+                        outPe.pastPendingTime += now - pe.pendingStartTime;
+                        outPe.hadPending = true;
+                    }
+                    for (int k = pe.stopReasons.size()-1; k >= 0; k--) {
+                        int type = pe.stopReasons.keyAt(k);
+                        outPe.stopReasons.put(type, outPe.stopReasons.get(type, 0)
+                                + pe.stopReasons.valueAt(k));
+                    }
+                }
+            }
+            if (mMaxTotalActive > out.mMaxTotalActive) {
+                out.mMaxTotalActive = mMaxTotalActive;
+            }
+            if (mMaxFgActive > out.mMaxFgActive) {
+                out.mMaxFgActive = mMaxFgActive;
+            }
+        }
+
+        void printDuration(PrintWriter pw, long period, long duration, int count, String suffix) {
+            float fraction = duration / (float) period;
+            int percent = (int) ((fraction * 100) + .5f);
+            if (percent > 0) {
+                pw.print(" ");
+                pw.print(percent);
+                pw.print("% ");
+                pw.print(count);
+                pw.print("x ");
+                pw.print(suffix);
+            } else if (count > 0) {
+                pw.print(" ");
+                pw.print(count);
+                pw.print("x ");
+                pw.print(suffix);
+            }
+        }
+
+        void dump(PrintWriter pw, String header, String prefix, long now, long nowElapsed,
+                int filterUid) {
+            final long period = getTotalTime(now);
+            pw.print(prefix); pw.print(header); pw.print(" at ");
+            pw.print(DateFormat.format("yyyy-MM-dd-HH-mm-ss", mStartClockTime).toString());
+            pw.print(" (");
+            TimeUtils.formatDuration(mStartElapsedTime, nowElapsed, pw);
+            pw.print(") over ");
+            TimeUtils.formatDuration(period, pw);
+            pw.println(":");
+            final int NE = mEntries.size();
+            for (int i = 0; i < NE; i++) {
+                int uid = mEntries.keyAt(i);
+                if (filterUid != -1 && filterUid != UserHandle.getAppId(uid)) {
+                    continue;
+                }
+                ArrayMap<String, PackageEntry> uidMap = mEntries.valueAt(i);
+                final int NP = uidMap.size();
+                for (int j = 0; j < NP; j++) {
+                    PackageEntry pe = uidMap.valueAt(j);
+                    pw.print(prefix); pw.print("  ");
+                    UserHandle.formatUid(pw, uid);
+                    pw.print(" / "); pw.print(uidMap.keyAt(j));
+                    pw.println(":");
+                    pw.print(prefix); pw.print("   ");
+                    printDuration(pw, period, pe.getPendingTime(now), pe.pendingCount, "pending");
+                    printDuration(pw, period, pe.getActiveTime(now), pe.activeCount, "active");
+                    printDuration(pw, period, pe.getActiveTopTime(now), pe.activeTopCount,
+                            "active-top");
+                    if (pe.pendingNesting > 0 || pe.hadPending) {
+                        pw.print(" (pending)");
+                    }
+                    if (pe.activeNesting > 0 || pe.hadActive) {
+                        pw.print(" (active)");
+                    }
+                    if (pe.activeTopNesting > 0 || pe.hadActiveTop) {
+                        pw.print(" (active-top)");
+                    }
+                    pw.println();
+                    if (pe.stopReasons.size() > 0) {
+                        pw.print(prefix); pw.print("    ");
+                        for (int k = 0; k < pe.stopReasons.size(); k++) {
+                            if (k > 0) {
+                                pw.print(", ");
+                            }
+                            pw.print(pe.stopReasons.valueAt(k));
+                            pw.print("x ");
+                            pw.print(JobParameters.getReasonName(pe.stopReasons.keyAt(k)));
+                        }
+                        pw.println();
+                    }
+                }
+            }
+            pw.print(prefix); pw.print("  Max concurrency: ");
+            pw.print(mMaxTotalActive); pw.print(" total, ");
+            pw.print(mMaxFgActive); pw.println(" foreground");
+        }
+
+        private void printPackageEntryState(ProtoOutputStream proto, long fieldId,
+                long duration, int count) {
+            final long token = proto.start(fieldId);
+            proto.write(DataSetProto.PackageEntryProto.State.DURATION_MS, duration);
+            proto.write(DataSetProto.PackageEntryProto.State.COUNT, count);
+            proto.end(token);
+        }
+
+        void dump(ProtoOutputStream proto, long fieldId, long now, long nowElapsed, int filterUid) {
+            final long token = proto.start(fieldId);
+            final long period = getTotalTime(now);
+
+            proto.write(DataSetProto.START_CLOCK_TIME_MS, mStartClockTime);
+            proto.write(DataSetProto.ELAPSED_TIME_MS, nowElapsed - mStartElapsedTime);
+            proto.write(DataSetProto.PERIOD_MS, period);
+
+            final int NE = mEntries.size();
+            for (int i = 0; i < NE; i++) {
+                int uid = mEntries.keyAt(i);
+                if (filterUid != -1 && filterUid != UserHandle.getAppId(uid)) {
+                    continue;
+                }
+                ArrayMap<String, PackageEntry> uidMap = mEntries.valueAt(i);
+                final int NP = uidMap.size();
+                for (int j = 0; j < NP; j++) {
+                    final long peToken = proto.start(DataSetProto.PACKAGE_ENTRIES);
+                    PackageEntry pe = uidMap.valueAt(j);
+
+                    proto.write(DataSetProto.PackageEntryProto.UID, uid);
+                    proto.write(DataSetProto.PackageEntryProto.PACKAGE_NAME, uidMap.keyAt(j));
+
+                    printPackageEntryState(proto, DataSetProto.PackageEntryProto.PENDING_STATE,
+                            pe.getPendingTime(now), pe.pendingCount);
+                    printPackageEntryState(proto, DataSetProto.PackageEntryProto.ACTIVE_STATE,
+                            pe.getActiveTime(now), pe.activeCount);
+                    printPackageEntryState(proto, DataSetProto.PackageEntryProto.ACTIVE_TOP_STATE,
+                            pe.getActiveTopTime(now), pe.activeTopCount);
+
+                    proto.write(DataSetProto.PackageEntryProto.PENDING,
+                          pe.pendingNesting > 0 || pe.hadPending);
+                    proto.write(DataSetProto.PackageEntryProto.ACTIVE,
+                          pe.activeNesting > 0 || pe.hadActive);
+                    proto.write(DataSetProto.PackageEntryProto.ACTIVE_TOP,
+                          pe.activeTopNesting > 0 || pe.hadActiveTop);
+
+                    for (int k = 0; k < pe.stopReasons.size(); k++) {
+                        final long srcToken =
+                                proto.start(DataSetProto.PackageEntryProto.STOP_REASONS);
+
+                        proto.write(DataSetProto.PackageEntryProto.StopReasonCount.REASON,
+                                pe.stopReasons.keyAt(k));
+                        proto.write(DataSetProto.PackageEntryProto.StopReasonCount.COUNT,
+                                pe.stopReasons.valueAt(k));
+
+                        proto.end(srcToken);
+                    }
+
+                    proto.end(peToken);
+                }
+            }
+
+            proto.write(DataSetProto.MAX_CONCURRENCY, mMaxTotalActive);
+            proto.write(DataSetProto.MAX_FOREGROUND_CONCURRENCY, mMaxFgActive);
+
+            proto.end(token);
+        }
+    }
+
+    void rebatchIfNeeded(long now) {
+        long totalTime = mCurDataSet.getTotalTime(now);
+        if (totalTime > BATCHING_TIME) {
+            DataSet last = mCurDataSet;
+            last.mSummedTime = totalTime;
+            mCurDataSet = new DataSet();
+            last.finish(mCurDataSet, now);
+            System.arraycopy(mLastDataSets, 0, mLastDataSets, 1, mLastDataSets.length-1);
+            mLastDataSets[0] = last;
+        }
+    }
+
+    public void notePending(JobStatus job) {
+        final long now = sUptimeMillisClock.millis();
+        job.madePending = now;
+        rebatchIfNeeded(now);
+        mCurDataSet.incPending(job.getSourceUid(), job.getSourcePackageName(), now);
+    }
+
+    public void noteNonpending(JobStatus job) {
+        final long now = sUptimeMillisClock.millis();
+        mCurDataSet.decPending(job.getSourceUid(), job.getSourcePackageName(), now);
+        rebatchIfNeeded(now);
+    }
+
+    public void noteActive(JobStatus job) {
+        final long now = sUptimeMillisClock.millis();
+        job.madeActive = now;
+        rebatchIfNeeded(now);
+        if (job.lastEvaluatedPriority >= JobInfo.PRIORITY_TOP_APP) {
+            mCurDataSet.incActiveTop(job.getSourceUid(), job.getSourcePackageName(), now);
+        } else {
+            mCurDataSet.incActive(job.getSourceUid(), job.getSourcePackageName(), now);
+        }
+        addEvent(job.getJob().isPeriodic() ? EVENT_START_PERIODIC_JOB :  EVENT_START_JOB,
+                job.getSourceUid(), job.getBatteryName(), job.getJobId(), 0, null);
+    }
+
+    public void noteInactive(JobStatus job, int stopReason, String debugReason) {
+        final long now = sUptimeMillisClock.millis();
+        if (job.lastEvaluatedPriority >= JobInfo.PRIORITY_TOP_APP) {
+            mCurDataSet.decActiveTop(job.getSourceUid(), job.getSourcePackageName(), now,
+                    stopReason);
+        } else {
+            mCurDataSet.decActive(job.getSourceUid(), job.getSourcePackageName(), now, stopReason);
+        }
+        rebatchIfNeeded(now);
+        addEvent(job.getJob().isPeriodic() ? EVENT_STOP_JOB :  EVENT_STOP_PERIODIC_JOB,
+                job.getSourceUid(), job.getBatteryName(), job.getJobId(), stopReason, debugReason);
+    }
+
+    public void noteConcurrency(int totalActive, int fgActive) {
+        if (totalActive > mCurDataSet.mMaxTotalActive) {
+            mCurDataSet.mMaxTotalActive = totalActive;
+        }
+        if (fgActive > mCurDataSet.mMaxFgActive) {
+            mCurDataSet.mMaxFgActive = fgActive;
+        }
+    }
+
+    public float getLoadFactor(JobStatus job) {
+        final int uid = job.getSourceUid();
+        final String pkg = job.getSourcePackageName();
+        PackageEntry cur = mCurDataSet.getEntry(uid, pkg);
+        PackageEntry last = mLastDataSets[0] != null ? mLastDataSets[0].getEntry(uid, pkg) : null;
+        if (cur == null && last == null) {
+            return 0;
+        }
+        final long now = sUptimeMillisClock.millis();
+        long time = 0;
+        if (cur != null) {
+            time += cur.getActiveTime(now) + cur.getPendingTime(now);
+        }
+        long period = mCurDataSet.getTotalTime(now);
+        if (last != null) {
+            time += last.getActiveTime(now) + last.getPendingTime(now);
+            period += mLastDataSets[0].getTotalTime(now);
+        }
+        return time / (float)period;
+    }
+
+    public void dump(PrintWriter pw, String prefix, int filterUid) {
+        final long now = sUptimeMillisClock.millis();
+        final long nowElapsed = sElapsedRealtimeClock.millis();
+        final DataSet total;
+        if (mLastDataSets[0] != null) {
+            total = new DataSet(mLastDataSets[0]);
+            mLastDataSets[0].addTo(total, now);
+        } else {
+            total = new DataSet(mCurDataSet);
+        }
+        mCurDataSet.addTo(total, now);
+        for (int i = 1; i < mLastDataSets.length; i++) {
+            if (mLastDataSets[i] != null) {
+                mLastDataSets[i].dump(pw, "Historical stats", prefix, now, nowElapsed, filterUid);
+                pw.println();
+            }
+        }
+        total.dump(pw, "Current stats", prefix, now, nowElapsed, filterUid);
+    }
+
+    public void dump(ProtoOutputStream proto, long fieldId, int filterUid) {
+        final long token = proto.start(fieldId);
+        final long now = sUptimeMillisClock.millis();
+        final long nowElapsed = sElapsedRealtimeClock.millis();
+
+        final DataSet total;
+        if (mLastDataSets[0] != null) {
+            total = new DataSet(mLastDataSets[0]);
+            mLastDataSets[0].addTo(total, now);
+        } else {
+            total = new DataSet(mCurDataSet);
+        }
+        mCurDataSet.addTo(total, now);
+
+        for (int i = 1; i < mLastDataSets.length; i++) {
+            if (mLastDataSets[i] != null) {
+                mLastDataSets[i].dump(proto, JobPackageTrackerDumpProto.HISTORICAL_STATS,
+                        now, nowElapsed, filterUid);
+            }
+        }
+        total.dump(proto, JobPackageTrackerDumpProto.CURRENT_STATS,
+                now, nowElapsed, filterUid);
+
+        proto.end(token);
+    }
+
+    public boolean dumpHistory(PrintWriter pw, String prefix, int filterUid) {
+        final int size = mEventIndices.size();
+        if (size <= 0) {
+            return false;
+        }
+        pw.println("  Job history:");
+        final long now = sElapsedRealtimeClock.millis();
+        for (int i=0; i<size; i++) {
+            final int index = mEventIndices.indexOf(i);
+            final int uid = mEventUids[index];
+            if (filterUid != -1 && filterUid != UserHandle.getAppId(uid)) {
+                continue;
+            }
+            final int cmd = mEventCmds[index] & EVENT_CMD_MASK;
+            if (cmd == EVENT_NULL) {
+                continue;
+            }
+            final String label;
+            switch (cmd) {
+                case EVENT_START_JOB:           label = "  START"; break;
+                case EVENT_STOP_JOB:            label = "   STOP"; break;
+                case EVENT_START_PERIODIC_JOB:  label = "START-P"; break;
+                case EVENT_STOP_PERIODIC_JOB:   label = " STOP-P"; break;
+                default:                        label = "     ??"; break;
+            }
+            pw.print(prefix);
+            TimeUtils.formatDuration(mEventTimes[index]-now, pw, TimeUtils.HUNDRED_DAY_FIELD_LEN);
+            pw.print(" ");
+            pw.print(label);
+            pw.print(": #");
+            UserHandle.formatUid(pw, uid);
+            pw.print("/");
+            pw.print(mEventJobIds[index]);
+            pw.print(" ");
+            pw.print(mEventTags[index]);
+            if (cmd == EVENT_STOP_JOB || cmd == EVENT_STOP_PERIODIC_JOB) {
+                pw.print(" ");
+                final String reason = mEventReasons[index];
+                if (reason != null) {
+                    pw.print(mEventReasons[index]);
+                } else {
+                    pw.print(JobParameters.getReasonName((mEventCmds[index] & EVENT_STOP_REASON_MASK)
+                            >> EVENT_STOP_REASON_SHIFT));
+                }
+            }
+            pw.println();
+        }
+        return true;
+    }
+
+    public void dumpHistory(ProtoOutputStream proto, long fieldId, int filterUid) {
+        final int size = mEventIndices.size();
+        if (size == 0) {
+            return;
+        }
+        final long token = proto.start(fieldId);
+
+        final long now = sElapsedRealtimeClock.millis();
+        for (int i = 0; i < size; i++) {
+            final int index = mEventIndices.indexOf(i);
+            final int uid = mEventUids[index];
+            if (filterUid != -1 && filterUid != UserHandle.getAppId(uid)) {
+                continue;
+            }
+            final int cmd = mEventCmds[index] & EVENT_CMD_MASK;
+            if (cmd == EVENT_NULL) {
+                continue;
+            }
+            final long heToken = proto.start(JobPackageHistoryProto.HISTORY_EVENT);
+
+            proto.write(JobPackageHistoryProto.HistoryEvent.EVENT, cmd);
+            proto.write(JobPackageHistoryProto.HistoryEvent.TIME_SINCE_EVENT_MS, now - mEventTimes[index]);
+            proto.write(JobPackageHistoryProto.HistoryEvent.UID, uid);
+            proto.write(JobPackageHistoryProto.HistoryEvent.JOB_ID, mEventJobIds[index]);
+            proto.write(JobPackageHistoryProto.HistoryEvent.TAG, mEventTags[index]);
+            if (cmd == EVENT_STOP_JOB || cmd == EVENT_STOP_PERIODIC_JOB) {
+                proto.write(JobPackageHistoryProto.HistoryEvent.STOP_REASON,
+                    (mEventCmds[index] & EVENT_STOP_REASON_MASK) >> EVENT_STOP_REASON_SHIFT);
+            }
+
+            proto.end(heToken);
+        }
+
+        proto.end(token);
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerService.java b/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerService.java
new file mode 100644
index 0000000..e44e902
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerService.java
@@ -0,0 +1,3657 @@
+/*
+ * Copyright (C) 2014 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.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED;
+import static android.content.pm.PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER;
+import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
+
+import android.annotation.NonNull;
+import android.annotation.UserIdInt;
+import android.app.Activity;
+import android.app.ActivityManager;
+import android.app.ActivityManagerInternal;
+import android.app.AlarmManager;
+import android.app.AppGlobals;
+import android.app.IUidObserver;
+import android.app.job.IJobScheduler;
+import android.app.job.JobInfo;
+import android.app.job.JobParameters;
+import android.app.job.JobProtoEnums;
+import android.app.job.JobScheduler;
+import android.app.job.JobService;
+import android.app.job.JobSnapshot;
+import android.app.job.JobWorkItem;
+import android.app.usage.UsageStatsManager;
+import android.app.usage.UsageStatsManagerInternal;
+import android.app.usage.UsageStatsManagerInternal.AppIdleStateChangeListener;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.IPackageManager;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.PackageManagerInternal;
+import android.content.pm.ParceledListSlice;
+import android.content.pm.ServiceInfo;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.BatteryStats;
+import android.os.BatteryStatsInternal;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.IThermalService;
+import android.os.IThermalStatusListener;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Process;
+import android.os.RemoteException;
+import android.os.ResultReceiver;
+import android.os.ServiceManager;
+import android.os.ShellCallback;
+import android.os.SystemClock;
+import android.os.Temperature;
+import android.os.UserHandle;
+import android.os.UserManagerInternal;
+import android.os.WorkSource;
+import android.provider.Settings;
+import android.text.format.DateUtils;
+import android.util.KeyValueListParser;
+import android.util.Log;
+import android.util.Slog;
+import android.util.SparseArray;
+import android.util.SparseIntArray;
+import android.util.StatsLog;
+import android.util.TimeUtils;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.app.IBatteryStats;
+import com.android.internal.util.ArrayUtils;
+import com.android.internal.util.DumpUtils;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.internal.util.Preconditions;
+import com.android.server.AppStateTracker;
+import com.android.server.DeviceIdleController;
+import com.android.server.FgThread;
+import com.android.server.LocalServices;
+import com.android.server.job.JobSchedulerServiceDumpProto.ActiveJob;
+import com.android.server.job.JobSchedulerServiceDumpProto.PendingJob;
+import com.android.server.job.JobSchedulerServiceDumpProto.RegisteredJob;
+import com.android.server.job.controllers.BackgroundJobsController;
+import com.android.server.job.controllers.BatteryController;
+import com.android.server.job.controllers.ConnectivityController;
+import com.android.server.job.controllers.ContentObserverController;
+import com.android.server.job.controllers.DeviceIdleJobsController;
+import com.android.server.job.controllers.IdleController;
+import com.android.server.job.controllers.JobStatus;
+import com.android.server.job.controllers.QuotaController;
+import com.android.server.job.controllers.StateController;
+import com.android.server.job.controllers.StorageController;
+import com.android.server.job.controllers.TimeController;
+
+import libcore.util.EmptyArray;
+
+import java.io.FileDescriptor;
+import java.io.PrintWriter;
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * Responsible for taking jobs representing work to be performed by a client app, and determining
+ * based on the criteria specified when that job should be run against the client application's
+ * endpoint.
+ * Implements logic for scheduling, and rescheduling jobs. The JobSchedulerService knows nothing
+ * about constraints, or the state of active jobs. It receives callbacks from the various
+ * controllers and completed jobs and operates accordingly.
+ *
+ * Note on locking: Any operations that manipulate {@link #mJobs} need to lock on that object.
+ * Any function with the suffix 'Locked' also needs to lock on {@link #mJobs}.
+ * @hide
+ */
+public class JobSchedulerService extends com.android.server.SystemService
+        implements StateChangedListener, JobCompletedListener {
+    public static final String TAG = "JobScheduler";
+    public static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
+    public static final boolean DEBUG_STANDBY = DEBUG || false;
+
+    /** The maximum number of concurrent jobs we run at one time. */
+    static final int MAX_JOB_CONTEXTS_COUNT = 16;
+    /** Enforce a per-app limit on scheduled jobs? */
+    private static final boolean ENFORCE_MAX_JOBS = true;
+    /** The maximum number of jobs that we allow an unprivileged app to schedule */
+    private static final int MAX_JOBS_PER_APP = 100;
+
+    @VisibleForTesting
+    public static Clock sSystemClock = Clock.systemUTC();
+    @VisibleForTesting
+    public static Clock sUptimeMillisClock = SystemClock.uptimeClock();
+    @VisibleForTesting
+    public static Clock sElapsedRealtimeClock = SystemClock.elapsedRealtimeClock();
+
+    /** Global local for all job scheduler state. */
+    final Object mLock = new Object();
+    /** Master list of jobs. */
+    final JobStore mJobs;
+    /** Tracking the standby bucket state of each app */
+    final StandbyTracker mStandbyTracker;
+    /** Tracking amount of time each package runs for. */
+    final JobPackageTracker mJobPackageTracker = new JobPackageTracker();
+    final JobConcurrencyManager mConcurrencyManager;
+
+    static final int MSG_JOB_EXPIRED = 0;
+    static final int MSG_CHECK_JOB = 1;
+    static final int MSG_STOP_JOB = 2;
+    static final int MSG_CHECK_JOB_GREEDY = 3;
+    static final int MSG_UID_STATE_CHANGED = 4;
+    static final int MSG_UID_GONE = 5;
+    static final int MSG_UID_ACTIVE = 6;
+    static final int MSG_UID_IDLE = 7;
+
+    /**
+     * Track Services that have currently active or pending jobs. The index is provided by
+     * {@link JobStatus#getServiceToken()}
+     */
+    final List<JobServiceContext> mActiveServices = new ArrayList<>();
+
+    /** List of controllers that will notify this service of updates to jobs. */
+    final List<StateController> mControllers;
+    /** Need direct access to this for testing. */
+    private final BatteryController mBatteryController;
+    /** Need direct access to this for testing. */
+    private final StorageController mStorageController;
+    /** Need directly for sending uid state changes */
+    private final DeviceIdleJobsController mDeviceIdleJobsController;
+    /** Needed to get remaining quota time. */
+    private final QuotaController mQuotaController;
+
+    /** Need directly for receiving thermal events */
+    private IThermalService mThermalService;
+    /** Thermal constraint. */
+    @GuardedBy("mLock")
+    private boolean mThermalConstraint = false;
+
+    /**
+     * Queue of pending jobs. The JobServiceContext class will receive jobs from this list
+     * when ready to execute them.
+     */
+    final ArrayList<JobStatus> mPendingJobs = new ArrayList<>();
+
+    int[] mStartedUsers = EmptyArray.INT;
+
+    final JobHandler mHandler;
+    final JobSchedulerStub mJobSchedulerStub;
+
+    PackageManagerInternal mLocalPM;
+    ActivityManagerInternal mActivityManagerInternal;
+    IBatteryStats mBatteryStats;
+    DeviceIdleController.LocalService mLocalDeviceIdleController;
+    AppStateTracker mAppStateTracker;
+    final UsageStatsManagerInternal mUsageStats;
+
+    /**
+     * Set to true once we are allowed to run third party apps.
+     */
+    boolean mReadyToRock;
+
+    /**
+     * What we last reported to DeviceIdleController about whether we are active.
+     */
+    boolean mReportedActive;
+
+    /**
+     * Are we currently in device-wide standby parole?
+     */
+    volatile boolean mInParole;
+
+    /**
+     * A mapping of which uids are currently in the foreground to their effective priority.
+     */
+    final SparseIntArray mUidPriorityOverride = new SparseIntArray();
+
+    /**
+     * Which uids are currently performing backups, so we shouldn't allow their jobs to run.
+     */
+    final SparseIntArray mBackingUpUids = new SparseIntArray();
+
+    /**
+     * Count standby heartbeats, and keep track of which beat each bucket's jobs will
+     * next become runnable.  Index into this array is by normalized bucket:
+     * { ACTIVE, WORKING, FREQUENT, RARE, NEVER }.  The ACTIVE and NEVER bucket
+     * milestones are not updated: ACTIVE apps get jobs whenever they ask for them,
+     * and NEVER apps don't get them at all.
+     */
+    final long[] mNextBucketHeartbeat = { 0, 0, 0, 0, Long.MAX_VALUE };
+    long mHeartbeat = 0;
+    long mLastHeartbeatTime = sElapsedRealtimeClock.millis();
+
+    /**
+     * Named indices into the STANDBY_BEATS array, for clarity in referring to
+     * specific buckets' bookkeeping.
+     */
+    public static final int ACTIVE_INDEX = 0;
+    public static final int WORKING_INDEX = 1;
+    public static final int FREQUENT_INDEX = 2;
+    public static final int RARE_INDEX = 3;
+    public static final int NEVER_INDEX = 4;
+
+    /**
+     * Bookkeeping about when jobs last run.  We keep our own record in heartbeat time,
+     * rather than rely on Usage Stats' timestamps, because heartbeat time can be
+     * manipulated for testing purposes and we need job runnability to track that rather
+     * than real time.
+     *
+     * Outer SparseArray slices by user handle; inner map of package name to heartbeat
+     * is a HashMap<> rather than ArrayMap<> because we expect O(hundreds) of keys
+     * and it will be accessed in a known-hot code path.
+     */
+    final SparseArray<HashMap<String, Long>> mLastJobHeartbeats = new SparseArray<>();
+
+    static final String HEARTBEAT_TAG = "*job.heartbeat*";
+    final HeartbeatAlarmListener mHeartbeatAlarm = new HeartbeatAlarmListener();
+
+    // -- Pre-allocated temporaries only for use in assignJobsToContextsLocked --
+
+    private class ConstantsObserver extends ContentObserver {
+        private ContentResolver mResolver;
+
+        public ConstantsObserver(Handler handler) {
+            super(handler);
+        }
+
+        public void start(ContentResolver resolver) {
+            mResolver = resolver;
+            mResolver.registerContentObserver(Settings.Global.getUriFor(
+                    Settings.Global.JOB_SCHEDULER_CONSTANTS), false, this);
+            updateConstants();
+        }
+
+        @Override
+        public void onChange(boolean selfChange, Uri uri) {
+            updateConstants();
+        }
+
+        private void updateConstants() {
+            synchronized (mLock) {
+                try {
+                    mConstants.updateConstantsLocked(Settings.Global.getString(mResolver,
+                            Settings.Global.JOB_SCHEDULER_CONSTANTS));
+                    for (int controller = 0; controller < mControllers.size(); controller++) {
+                        final StateController sc = mControllers.get(controller);
+                        sc.onConstantsUpdatedLocked();
+                    }
+                } catch (IllegalArgumentException e) {
+                    // Failed to parse the settings string, log this and move on
+                    // with defaults.
+                    Slog.e(TAG, "Bad jobscheduler settings", e);
+                }
+            }
+
+            if (mConstants.USE_HEARTBEATS) {
+                // Reset the heartbeat alarm based on the new heartbeat duration
+                setNextHeartbeatAlarm();
+            }
+        }
+    }
+
+    /**
+     *  Thermal event received from Thermal Service
+     */
+    private final class ThermalStatusListener extends IThermalStatusListener.Stub {
+        @Override public void onStatusChange(int status) {
+            // Throttle for Temperature.THROTTLING_SEVERE and above
+            synchronized (mLock) {
+                mThermalConstraint = status >= Temperature.THROTTLING_SEVERE;
+            }
+            onControllerStateChanged();
+        }
+    }
+
+    static class MaxJobCounts {
+        private final KeyValueListParser.IntValue mTotal;
+        private final KeyValueListParser.IntValue mMaxBg;
+        private final KeyValueListParser.IntValue mMinBg;
+
+        MaxJobCounts(int totalDefault, String totalKey,
+                int maxBgDefault, String maxBgKey, int minBgDefault, String minBgKey) {
+            mTotal = new KeyValueListParser.IntValue(totalKey, totalDefault);
+            mMaxBg = new KeyValueListParser.IntValue(maxBgKey, maxBgDefault);
+            mMinBg = new KeyValueListParser.IntValue(minBgKey, minBgDefault);
+        }
+
+        public void parse(KeyValueListParser parser) {
+            mTotal.parse(parser);
+            mMaxBg.parse(parser);
+            mMinBg.parse(parser);
+
+            if (mTotal.getValue() < 1) {
+                mTotal.setValue(1);
+            } else if (mTotal.getValue() > MAX_JOB_CONTEXTS_COUNT) {
+                mTotal.setValue(MAX_JOB_CONTEXTS_COUNT);
+            }
+
+            if (mMaxBg.getValue() < 1) {
+                mMaxBg.setValue(1);
+            } else if (mMaxBg.getValue() > mTotal.getValue()) {
+                mMaxBg.setValue(mTotal.getValue());
+            }
+            if (mMinBg.getValue() < 0) {
+                mMinBg.setValue(0);
+            } else {
+                if (mMinBg.getValue() > mMaxBg.getValue()) {
+                    mMinBg.setValue(mMaxBg.getValue());
+                }
+                if (mMinBg.getValue() >= mTotal.getValue()) {
+                    mMinBg.setValue(mTotal.getValue() - 1);
+                }
+            }
+        }
+
+        /** Total number of jobs to run simultaneously. */
+        public int getMaxTotal() {
+            return mTotal.getValue();
+        }
+
+        /** Max number of BG (== owned by non-TOP apps) jobs to run simultaneously. */
+        public int getMaxBg() {
+            return mMaxBg.getValue();
+        }
+
+        /**
+         * We try to run at least this many BG (== owned by non-TOP apps) jobs, when there are any
+         * pending, rather than always running the TOTAL number of FG jobs.
+         */
+        public int getMinBg() {
+            return mMinBg.getValue();
+        }
+
+        public void dump(PrintWriter pw, String prefix) {
+            mTotal.dump(pw, prefix);
+            mMaxBg.dump(pw, prefix);
+            mMinBg.dump(pw, prefix);
+        }
+
+        public void dumpProto(ProtoOutputStream proto, long fieldId) {
+            final long token = proto.start(fieldId);
+            mTotal.dumpProto(proto, MaxJobCountsProto.TOTAL_JOBS);
+            mMaxBg.dumpProto(proto, MaxJobCountsProto.MAX_BG);
+            mMinBg.dumpProto(proto, MaxJobCountsProto.MIN_BG);
+            proto.end(token);
+        }
+    }
+
+    /** {@link MaxJobCounts} for each memory trim level. */
+    static class MaxJobCountsPerMemoryTrimLevel {
+        public final MaxJobCounts normal;
+        public final MaxJobCounts moderate;
+        public final MaxJobCounts low;
+        public final MaxJobCounts critical;
+
+        MaxJobCountsPerMemoryTrimLevel(
+                MaxJobCounts normal,
+                MaxJobCounts moderate, MaxJobCounts low,
+                MaxJobCounts critical) {
+            this.normal = normal;
+            this.moderate = moderate;
+            this.low = low;
+            this.critical = critical;
+        }
+
+        public void dumpProto(ProtoOutputStream proto, long fieldId) {
+            final long token = proto.start(fieldId);
+            normal.dumpProto(proto, MaxJobCountsPerMemoryTrimLevelProto.NORMAL);
+            moderate.dumpProto(proto, MaxJobCountsPerMemoryTrimLevelProto.MODERATE);
+            low.dumpProto(proto, MaxJobCountsPerMemoryTrimLevelProto.LOW);
+            critical.dumpProto(proto, MaxJobCountsPerMemoryTrimLevelProto.CRITICAL);
+            proto.end(token);
+        }
+    }
+
+    /**
+     * All times are in milliseconds. These constants are kept synchronized with the system
+     * global Settings. Any access to this class or its fields should be done while
+     * holding the JobSchedulerService.mLock lock.
+     */
+    public static class Constants {
+        // Key names stored in the settings value.
+        private static final String KEY_MIN_IDLE_COUNT = "min_idle_count";
+        private static final String KEY_MIN_CHARGING_COUNT = "min_charging_count";
+        private static final String KEY_MIN_BATTERY_NOT_LOW_COUNT = "min_battery_not_low_count";
+        private static final String KEY_MIN_STORAGE_NOT_LOW_COUNT = "min_storage_not_low_count";
+        private static final String KEY_MIN_CONNECTIVITY_COUNT = "min_connectivity_count";
+        private static final String KEY_MIN_CONTENT_COUNT = "min_content_count";
+        private static final String KEY_MIN_READY_JOBS_COUNT = "min_ready_jobs_count";
+        private static final String KEY_MIN_READY_NON_ACTIVE_JOBS_COUNT =
+                "min_ready_non_active_jobs_count";
+        private static final String KEY_MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS =
+                "max_non_active_job_batch_delay_ms";
+        private static final String KEY_HEAVY_USE_FACTOR = "heavy_use_factor";
+        private static final String KEY_MODERATE_USE_FACTOR = "moderate_use_factor";
+
+        // The following values used to be used on P and below. Do not reuse them.
+        private static final String DEPRECATED_KEY_FG_JOB_COUNT = "fg_job_count";
+        private static final String DEPRECATED_KEY_BG_NORMAL_JOB_COUNT = "bg_normal_job_count";
+        private static final String DEPRECATED_KEY_BG_MODERATE_JOB_COUNT = "bg_moderate_job_count";
+        private static final String DEPRECATED_KEY_BG_LOW_JOB_COUNT = "bg_low_job_count";
+        private static final String DEPRECATED_KEY_BG_CRITICAL_JOB_COUNT = "bg_critical_job_count";
+
+        private static final String KEY_MAX_STANDARD_RESCHEDULE_COUNT
+                = "max_standard_reschedule_count";
+        private static final String KEY_MAX_WORK_RESCHEDULE_COUNT = "max_work_reschedule_count";
+        private static final String KEY_MIN_LINEAR_BACKOFF_TIME = "min_linear_backoff_time";
+        private static final String KEY_MIN_EXP_BACKOFF_TIME = "min_exp_backoff_time";
+        private static final String KEY_STANDBY_HEARTBEAT_TIME = "standby_heartbeat_time";
+        private static final String KEY_STANDBY_WORKING_BEATS = "standby_working_beats";
+        private static final String KEY_STANDBY_FREQUENT_BEATS = "standby_frequent_beats";
+        private static final String KEY_STANDBY_RARE_BEATS = "standby_rare_beats";
+        private static final String KEY_CONN_CONGESTION_DELAY_FRAC = "conn_congestion_delay_frac";
+        private static final String KEY_CONN_PREFETCH_RELAX_FRAC = "conn_prefetch_relax_frac";
+        private static final String KEY_USE_HEARTBEATS = "use_heartbeats";
+
+        private static final int DEFAULT_MIN_IDLE_COUNT = 1;
+        private static final int DEFAULT_MIN_CHARGING_COUNT = 1;
+        private static final int DEFAULT_MIN_BATTERY_NOT_LOW_COUNT = 1;
+        private static final int DEFAULT_MIN_STORAGE_NOT_LOW_COUNT = 1;
+        private static final int DEFAULT_MIN_CONNECTIVITY_COUNT = 1;
+        private static final int DEFAULT_MIN_CONTENT_COUNT = 1;
+        private static final int DEFAULT_MIN_READY_JOBS_COUNT = 1;
+        private static final int DEFAULT_MIN_READY_NON_ACTIVE_JOBS_COUNT = 5;
+        private static final long DEFAULT_MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS = 31 * MINUTE_IN_MILLIS;
+        private static final float DEFAULT_HEAVY_USE_FACTOR = .9f;
+        private static final float DEFAULT_MODERATE_USE_FACTOR = .5f;
+        private static final int DEFAULT_MAX_STANDARD_RESCHEDULE_COUNT = Integer.MAX_VALUE;
+        private static final int DEFAULT_MAX_WORK_RESCHEDULE_COUNT = Integer.MAX_VALUE;
+        private static final long DEFAULT_MIN_LINEAR_BACKOFF_TIME = JobInfo.MIN_BACKOFF_MILLIS;
+        private static final long DEFAULT_MIN_EXP_BACKOFF_TIME = JobInfo.MIN_BACKOFF_MILLIS;
+        private static final long DEFAULT_STANDBY_HEARTBEAT_TIME = 11 * 60 * 1000L;
+        private static final int DEFAULT_STANDBY_WORKING_BEATS = 11;  // ~ 2 hours, with 11min beats
+        private static final int DEFAULT_STANDBY_FREQUENT_BEATS = 43; // ~ 8 hours
+        private static final int DEFAULT_STANDBY_RARE_BEATS = 130; // ~ 24 hours
+        private static final float DEFAULT_CONN_CONGESTION_DELAY_FRAC = 0.5f;
+        private static final float DEFAULT_CONN_PREFETCH_RELAX_FRAC = 0.5f;
+        private static final boolean DEFAULT_USE_HEARTBEATS = false;
+
+        /**
+         * Minimum # of idle jobs that must be ready in order to force the JMS to schedule things
+         * early.
+         */
+        int MIN_IDLE_COUNT = DEFAULT_MIN_IDLE_COUNT;
+        /**
+         * Minimum # of charging jobs that must be ready in order to force the JMS to schedule
+         * things early.
+         */
+        int MIN_CHARGING_COUNT = DEFAULT_MIN_CHARGING_COUNT;
+        /**
+         * Minimum # of "battery not low" jobs that must be ready in order to force the JMS to
+         * schedule things early.
+         */
+        int MIN_BATTERY_NOT_LOW_COUNT = DEFAULT_MIN_BATTERY_NOT_LOW_COUNT;
+        /**
+         * Minimum # of "storage not low" jobs that must be ready in order to force the JMS to
+         * schedule things early.
+         */
+        int MIN_STORAGE_NOT_LOW_COUNT = DEFAULT_MIN_STORAGE_NOT_LOW_COUNT;
+        /**
+         * Minimum # of connectivity jobs that must be ready in order to force the JMS to schedule
+         * things early.  1 == Run connectivity jobs as soon as ready.
+         */
+        int MIN_CONNECTIVITY_COUNT = DEFAULT_MIN_CONNECTIVITY_COUNT;
+        /**
+         * Minimum # of content trigger jobs that must be ready in order to force the JMS to
+         * schedule things early.
+         */
+        int MIN_CONTENT_COUNT = DEFAULT_MIN_CONTENT_COUNT;
+        /**
+         * Minimum # of jobs (with no particular constraints) for which the JMS will be happy
+         * running some work early.  This (and thus the other min counts) is now set to 1, to
+         * prevent any batching at this level.  Since we now do batching through doze, that is
+         * a much better mechanism.
+         */
+        int MIN_READY_JOBS_COUNT = DEFAULT_MIN_READY_JOBS_COUNT;
+
+        /**
+         * Minimum # of non-ACTIVE jobs for which the JMS will be happy running some work early.
+         */
+        int MIN_READY_NON_ACTIVE_JOBS_COUNT = DEFAULT_MIN_READY_NON_ACTIVE_JOBS_COUNT;
+
+        /**
+         * Don't batch a non-ACTIVE job if it's been delayed due to force batching attempts for
+         * at least this amount of time.
+         */
+        long MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS = DEFAULT_MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS;
+
+        /**
+         * This is the job execution factor that is considered to be heavy use of the system.
+         */
+        float HEAVY_USE_FACTOR = DEFAULT_HEAVY_USE_FACTOR;
+        /**
+         * This is the job execution factor that is considered to be moderate use of the system.
+         */
+        float MODERATE_USE_FACTOR = DEFAULT_MODERATE_USE_FACTOR;
+
+        // Max job counts for screen on / off, for each memory trim level.
+        final MaxJobCountsPerMemoryTrimLevel MAX_JOB_COUNTS_SCREEN_ON =
+                new MaxJobCountsPerMemoryTrimLevel(
+                        new MaxJobCounts(
+                                8, "max_job_total_on_normal",
+                                6, "max_job_max_bg_on_normal",
+                                2, "max_job_min_bg_on_normal"),
+                        new MaxJobCounts(
+                                8, "max_job_total_on_moderate",
+                                4, "max_job_max_bg_on_moderate",
+                                2, "max_job_min_bg_on_moderate"),
+                        new MaxJobCounts(
+                                5, "max_job_total_on_low",
+                                1, "max_job_max_bg_on_low",
+                                1, "max_job_min_bg_on_low"),
+                        new MaxJobCounts(
+                                5, "max_job_total_on_critical",
+                                1, "max_job_max_bg_on_critical",
+                                1, "max_job_min_bg_on_critical"));
+
+        final MaxJobCountsPerMemoryTrimLevel MAX_JOB_COUNTS_SCREEN_OFF =
+                new MaxJobCountsPerMemoryTrimLevel(
+                        new MaxJobCounts(
+                                10, "max_job_total_off_normal",
+                                6, "max_job_max_bg_off_normal",
+                                2, "max_job_min_bg_off_normal"),
+                        new MaxJobCounts(
+                                10, "max_job_total_off_moderate",
+                                4, "max_job_max_bg_off_moderate",
+                                2, "max_job_min_bg_off_moderate"),
+                        new MaxJobCounts(
+                                5, "max_job_total_off_low",
+                                1, "max_job_max_bg_off_low",
+                                1, "max_job_min_bg_off_low"),
+                        new MaxJobCounts(
+                                5, "max_job_total_off_critical",
+                                1, "max_job_max_bg_off_critical",
+                                1, "max_job_min_bg_off_critical"));
+
+
+        /** Wait for this long after screen off before increasing the job concurrency. */
+        final KeyValueListParser.IntValue SCREEN_OFF_JOB_CONCURRENCY_INCREASE_DELAY_MS =
+                new KeyValueListParser.IntValue(
+                        "screen_off_job_concurrency_increase_delay_ms", 30_000);
+
+        /**
+         * The maximum number of times we allow a job to have itself rescheduled before
+         * giving up on it, for standard jobs.
+         */
+        int MAX_STANDARD_RESCHEDULE_COUNT = DEFAULT_MAX_STANDARD_RESCHEDULE_COUNT;
+        /**
+         * The maximum number of times we allow a job to have itself rescheduled before
+         * giving up on it, for jobs that are executing work.
+         */
+        int MAX_WORK_RESCHEDULE_COUNT = DEFAULT_MAX_WORK_RESCHEDULE_COUNT;
+        /**
+         * The minimum backoff time to allow for linear backoff.
+         */
+        long MIN_LINEAR_BACKOFF_TIME = DEFAULT_MIN_LINEAR_BACKOFF_TIME;
+        /**
+         * The minimum backoff time to allow for exponential backoff.
+         */
+        long MIN_EXP_BACKOFF_TIME = DEFAULT_MIN_EXP_BACKOFF_TIME;
+        /**
+         * How often we recalculate runnability based on apps' standby bucket assignment.
+         * This should be prime relative to common time interval lengths such as a quarter-
+         * hour or day, so that the heartbeat drifts relative to wall-clock milestones.
+         */
+        long STANDBY_HEARTBEAT_TIME = DEFAULT_STANDBY_HEARTBEAT_TIME;
+        /**
+         * Mapping: standby bucket -> number of heartbeats between each sweep of that
+         * bucket's jobs.
+         *
+         * Bucket assignments as recorded in the JobStatus objects are normalized to be
+         * indices into this array, rather than the raw constants used
+         * by AppIdleHistory.
+         */
+        final int[] STANDBY_BEATS = {
+                0,
+                DEFAULT_STANDBY_WORKING_BEATS,
+                DEFAULT_STANDBY_FREQUENT_BEATS,
+                DEFAULT_STANDBY_RARE_BEATS
+        };
+        /**
+         * The fraction of a job's running window that must pass before we
+         * consider running it when the network is congested.
+         */
+        public float CONN_CONGESTION_DELAY_FRAC = DEFAULT_CONN_CONGESTION_DELAY_FRAC;
+        /**
+         * The fraction of a prefetch job's running window that must pass before
+         * we consider matching it against a metered network.
+         */
+        public float CONN_PREFETCH_RELAX_FRAC = DEFAULT_CONN_PREFETCH_RELAX_FRAC;
+        /**
+         * Whether to use heartbeats or rolling window for quota management. True will use
+         * heartbeats, false will use a rolling window.
+         */
+        public boolean USE_HEARTBEATS = DEFAULT_USE_HEARTBEATS;
+
+        private final KeyValueListParser mParser = new KeyValueListParser(',');
+
+        void updateConstantsLocked(String value) {
+            try {
+                mParser.setString(value);
+            } catch (Exception e) {
+                // Failed to parse the settings string, log this and move on
+                // with defaults.
+                Slog.e(TAG, "Bad jobscheduler settings", e);
+            }
+
+            MIN_IDLE_COUNT = mParser.getInt(KEY_MIN_IDLE_COUNT,
+                    DEFAULT_MIN_IDLE_COUNT);
+            MIN_CHARGING_COUNT = mParser.getInt(KEY_MIN_CHARGING_COUNT,
+                    DEFAULT_MIN_CHARGING_COUNT);
+            MIN_BATTERY_NOT_LOW_COUNT = mParser.getInt(KEY_MIN_BATTERY_NOT_LOW_COUNT,
+                    DEFAULT_MIN_BATTERY_NOT_LOW_COUNT);
+            MIN_STORAGE_NOT_LOW_COUNT = mParser.getInt(KEY_MIN_STORAGE_NOT_LOW_COUNT,
+                    DEFAULT_MIN_STORAGE_NOT_LOW_COUNT);
+            MIN_CONNECTIVITY_COUNT = mParser.getInt(KEY_MIN_CONNECTIVITY_COUNT,
+                    DEFAULT_MIN_CONNECTIVITY_COUNT);
+            MIN_CONTENT_COUNT = mParser.getInt(KEY_MIN_CONTENT_COUNT,
+                    DEFAULT_MIN_CONTENT_COUNT);
+            MIN_READY_JOBS_COUNT = mParser.getInt(KEY_MIN_READY_JOBS_COUNT,
+                    DEFAULT_MIN_READY_JOBS_COUNT);
+            MIN_READY_NON_ACTIVE_JOBS_COUNT = mParser.getInt(
+                    KEY_MIN_READY_NON_ACTIVE_JOBS_COUNT,
+                    DEFAULT_MIN_READY_NON_ACTIVE_JOBS_COUNT);
+            MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS = mParser.getLong(
+                    KEY_MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS,
+                    DEFAULT_MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS);
+            HEAVY_USE_FACTOR = mParser.getFloat(KEY_HEAVY_USE_FACTOR,
+                    DEFAULT_HEAVY_USE_FACTOR);
+            MODERATE_USE_FACTOR = mParser.getFloat(KEY_MODERATE_USE_FACTOR,
+                    DEFAULT_MODERATE_USE_FACTOR);
+
+            MAX_JOB_COUNTS_SCREEN_ON.normal.parse(mParser);
+            MAX_JOB_COUNTS_SCREEN_ON.moderate.parse(mParser);
+            MAX_JOB_COUNTS_SCREEN_ON.low.parse(mParser);
+            MAX_JOB_COUNTS_SCREEN_ON.critical.parse(mParser);
+
+            MAX_JOB_COUNTS_SCREEN_OFF.normal.parse(mParser);
+            MAX_JOB_COUNTS_SCREEN_OFF.moderate.parse(mParser);
+            MAX_JOB_COUNTS_SCREEN_OFF.low.parse(mParser);
+            MAX_JOB_COUNTS_SCREEN_OFF.critical.parse(mParser);
+
+            SCREEN_OFF_JOB_CONCURRENCY_INCREASE_DELAY_MS.parse(mParser);
+
+            MAX_STANDARD_RESCHEDULE_COUNT = mParser.getInt(KEY_MAX_STANDARD_RESCHEDULE_COUNT,
+                    DEFAULT_MAX_STANDARD_RESCHEDULE_COUNT);
+            MAX_WORK_RESCHEDULE_COUNT = mParser.getInt(KEY_MAX_WORK_RESCHEDULE_COUNT,
+                    DEFAULT_MAX_WORK_RESCHEDULE_COUNT);
+            MIN_LINEAR_BACKOFF_TIME = mParser.getDurationMillis(KEY_MIN_LINEAR_BACKOFF_TIME,
+                    DEFAULT_MIN_LINEAR_BACKOFF_TIME);
+            MIN_EXP_BACKOFF_TIME = mParser.getDurationMillis(KEY_MIN_EXP_BACKOFF_TIME,
+                    DEFAULT_MIN_EXP_BACKOFF_TIME);
+            STANDBY_HEARTBEAT_TIME = mParser.getDurationMillis(KEY_STANDBY_HEARTBEAT_TIME,
+                    DEFAULT_STANDBY_HEARTBEAT_TIME);
+            STANDBY_BEATS[WORKING_INDEX] = mParser.getInt(KEY_STANDBY_WORKING_BEATS,
+                    DEFAULT_STANDBY_WORKING_BEATS);
+            STANDBY_BEATS[FREQUENT_INDEX] = mParser.getInt(KEY_STANDBY_FREQUENT_BEATS,
+                    DEFAULT_STANDBY_FREQUENT_BEATS);
+            STANDBY_BEATS[RARE_INDEX] = mParser.getInt(KEY_STANDBY_RARE_BEATS,
+                    DEFAULT_STANDBY_RARE_BEATS);
+            CONN_CONGESTION_DELAY_FRAC = mParser.getFloat(KEY_CONN_CONGESTION_DELAY_FRAC,
+                    DEFAULT_CONN_CONGESTION_DELAY_FRAC);
+            CONN_PREFETCH_RELAX_FRAC = mParser.getFloat(KEY_CONN_PREFETCH_RELAX_FRAC,
+                    DEFAULT_CONN_PREFETCH_RELAX_FRAC);
+            USE_HEARTBEATS = mParser.getBoolean(KEY_USE_HEARTBEATS, DEFAULT_USE_HEARTBEATS);
+        }
+
+        void dump(IndentingPrintWriter pw) {
+            pw.println("Settings:");
+            pw.increaseIndent();
+            pw.printPair(KEY_MIN_IDLE_COUNT, MIN_IDLE_COUNT).println();
+            pw.printPair(KEY_MIN_CHARGING_COUNT, MIN_CHARGING_COUNT).println();
+            pw.printPair(KEY_MIN_BATTERY_NOT_LOW_COUNT, MIN_BATTERY_NOT_LOW_COUNT).println();
+            pw.printPair(KEY_MIN_STORAGE_NOT_LOW_COUNT, MIN_STORAGE_NOT_LOW_COUNT).println();
+            pw.printPair(KEY_MIN_CONNECTIVITY_COUNT, MIN_CONNECTIVITY_COUNT).println();
+            pw.printPair(KEY_MIN_CONTENT_COUNT, MIN_CONTENT_COUNT).println();
+            pw.printPair(KEY_MIN_READY_JOBS_COUNT, MIN_READY_JOBS_COUNT).println();
+            pw.printPair(KEY_MIN_READY_NON_ACTIVE_JOBS_COUNT,
+                    MIN_READY_NON_ACTIVE_JOBS_COUNT).println();
+            pw.printPair(KEY_MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS,
+                    MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS).println();
+            pw.printPair(KEY_HEAVY_USE_FACTOR, HEAVY_USE_FACTOR).println();
+            pw.printPair(KEY_MODERATE_USE_FACTOR, MODERATE_USE_FACTOR).println();
+
+            MAX_JOB_COUNTS_SCREEN_ON.normal.dump(pw, "");
+            MAX_JOB_COUNTS_SCREEN_ON.moderate.dump(pw, "");
+            MAX_JOB_COUNTS_SCREEN_ON.low.dump(pw, "");
+            MAX_JOB_COUNTS_SCREEN_ON.critical.dump(pw, "");
+
+            MAX_JOB_COUNTS_SCREEN_OFF.normal.dump(pw, "");
+            MAX_JOB_COUNTS_SCREEN_OFF.moderate.dump(pw, "");
+            MAX_JOB_COUNTS_SCREEN_OFF.low.dump(pw, "");
+            MAX_JOB_COUNTS_SCREEN_OFF.critical.dump(pw, "");
+
+            SCREEN_OFF_JOB_CONCURRENCY_INCREASE_DELAY_MS.dump(pw, "");
+
+            pw.printPair(KEY_MAX_STANDARD_RESCHEDULE_COUNT, MAX_STANDARD_RESCHEDULE_COUNT).println();
+            pw.printPair(KEY_MAX_WORK_RESCHEDULE_COUNT, MAX_WORK_RESCHEDULE_COUNT).println();
+            pw.printPair(KEY_MIN_LINEAR_BACKOFF_TIME, MIN_LINEAR_BACKOFF_TIME).println();
+            pw.printPair(KEY_MIN_EXP_BACKOFF_TIME, MIN_EXP_BACKOFF_TIME).println();
+            pw.printPair(KEY_STANDBY_HEARTBEAT_TIME, STANDBY_HEARTBEAT_TIME).println();
+            pw.print("standby_beats={");
+            pw.print(STANDBY_BEATS[0]);
+            for (int i = 1; i < STANDBY_BEATS.length; i++) {
+                pw.print(", ");
+                pw.print(STANDBY_BEATS[i]);
+            }
+            pw.println('}');
+            pw.printPair(KEY_CONN_CONGESTION_DELAY_FRAC, CONN_CONGESTION_DELAY_FRAC).println();
+            pw.printPair(KEY_CONN_PREFETCH_RELAX_FRAC, CONN_PREFETCH_RELAX_FRAC).println();
+            pw.printPair(KEY_USE_HEARTBEATS, USE_HEARTBEATS).println();
+
+            pw.decreaseIndent();
+        }
+
+        void dump(ProtoOutputStream proto) {
+            proto.write(ConstantsProto.MIN_IDLE_COUNT, MIN_IDLE_COUNT);
+            proto.write(ConstantsProto.MIN_CHARGING_COUNT, MIN_CHARGING_COUNT);
+            proto.write(ConstantsProto.MIN_BATTERY_NOT_LOW_COUNT, MIN_BATTERY_NOT_LOW_COUNT);
+            proto.write(ConstantsProto.MIN_STORAGE_NOT_LOW_COUNT, MIN_STORAGE_NOT_LOW_COUNT);
+            proto.write(ConstantsProto.MIN_CONNECTIVITY_COUNT, MIN_CONNECTIVITY_COUNT);
+            proto.write(ConstantsProto.MIN_CONTENT_COUNT, MIN_CONTENT_COUNT);
+            proto.write(ConstantsProto.MIN_READY_JOBS_COUNT, MIN_READY_JOBS_COUNT);
+            proto.write(ConstantsProto.MIN_READY_NON_ACTIVE_JOBS_COUNT,
+                    MIN_READY_NON_ACTIVE_JOBS_COUNT);
+            proto.write(ConstantsProto.MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS,
+                    MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS);
+            proto.write(ConstantsProto.HEAVY_USE_FACTOR, HEAVY_USE_FACTOR);
+            proto.write(ConstantsProto.MODERATE_USE_FACTOR, MODERATE_USE_FACTOR);
+
+            MAX_JOB_COUNTS_SCREEN_ON.dumpProto(proto, ConstantsProto.MAX_JOB_COUNTS_SCREEN_ON);
+            MAX_JOB_COUNTS_SCREEN_OFF.dumpProto(proto, ConstantsProto.MAX_JOB_COUNTS_SCREEN_OFF);
+
+            SCREEN_OFF_JOB_CONCURRENCY_INCREASE_DELAY_MS.dumpProto(proto,
+                    ConstantsProto.SCREEN_OFF_JOB_CONCURRENCY_INCREASE_DELAY_MS);
+
+            proto.write(ConstantsProto.MAX_STANDARD_RESCHEDULE_COUNT, MAX_STANDARD_RESCHEDULE_COUNT);
+            proto.write(ConstantsProto.MAX_WORK_RESCHEDULE_COUNT, MAX_WORK_RESCHEDULE_COUNT);
+            proto.write(ConstantsProto.MIN_LINEAR_BACKOFF_TIME_MS, MIN_LINEAR_BACKOFF_TIME);
+            proto.write(ConstantsProto.MIN_EXP_BACKOFF_TIME_MS, MIN_EXP_BACKOFF_TIME);
+            proto.write(ConstantsProto.STANDBY_HEARTBEAT_TIME_MS, STANDBY_HEARTBEAT_TIME);
+            for (int period : STANDBY_BEATS) {
+                proto.write(ConstantsProto.STANDBY_BEATS, period);
+            }
+            proto.write(ConstantsProto.CONN_CONGESTION_DELAY_FRAC, CONN_CONGESTION_DELAY_FRAC);
+            proto.write(ConstantsProto.CONN_PREFETCH_RELAX_FRAC, CONN_PREFETCH_RELAX_FRAC);
+            proto.write(ConstantsProto.USE_HEARTBEATS, USE_HEARTBEATS);
+        }
+    }
+
+    final Constants mConstants;
+    final ConstantsObserver mConstantsObserver;
+
+    static final Comparator<JobStatus> mEnqueueTimeComparator = (o1, o2) -> {
+        if (o1.enqueueTime < o2.enqueueTime) {
+            return -1;
+        }
+        return o1.enqueueTime > o2.enqueueTime ? 1 : 0;
+    };
+
+    static <T> void addOrderedItem(ArrayList<T> array, T newItem, Comparator<T> comparator) {
+        int where = Collections.binarySearch(array, newItem, comparator);
+        if (where < 0) {
+            where = ~where;
+        }
+        array.add(where, newItem);
+    }
+
+    /**
+     * Cleans up outstanding jobs when a package is removed. Even if it's being replaced later we
+     * still clean up. On reinstall the package will have a new uid.
+     */
+    private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            final String action = intent.getAction();
+            if (DEBUG) {
+                Slog.d(TAG, "Receieved: " + action);
+            }
+            final String pkgName = getPackageName(intent);
+            final int pkgUid = intent.getIntExtra(Intent.EXTRA_UID, -1);
+
+            if (Intent.ACTION_PACKAGE_CHANGED.equals(action)) {
+                // Purge the app's jobs if the whole package was just disabled.  When this is
+                // the case the component name will be a bare package name.
+                if (pkgName != null && pkgUid != -1) {
+                    final String[] changedComponents = intent.getStringArrayExtra(
+                            Intent.EXTRA_CHANGED_COMPONENT_NAME_LIST);
+                    if (changedComponents != null) {
+                        for (String component : changedComponents) {
+                            if (component.equals(pkgName)) {
+                                if (DEBUG) {
+                                    Slog.d(TAG, "Package state change: " + pkgName);
+                                }
+                                try {
+                                    final int userId = UserHandle.getUserId(pkgUid);
+                                    IPackageManager pm = AppGlobals.getPackageManager();
+                                    final int state = pm.getApplicationEnabledSetting(pkgName, userId);
+                                    if (state == COMPONENT_ENABLED_STATE_DISABLED
+                                            || state ==  COMPONENT_ENABLED_STATE_DISABLED_USER) {
+                                        if (DEBUG) {
+                                            Slog.d(TAG, "Removing jobs for package " + pkgName
+                                                    + " in user " + userId);
+                                        }
+                                        cancelJobsForPackageAndUid(pkgName, pkgUid,
+                                                "app disabled");
+                                    }
+                                } catch (RemoteException|IllegalArgumentException e) {
+                                    /*
+                                     * IllegalArgumentException means that the package doesn't exist.
+                                     * This arises when PACKAGE_CHANGED broadcast delivery has lagged
+                                     * behind outright uninstall, so by the time we try to act it's gone.
+                                     * We don't need to act on this PACKAGE_CHANGED when this happens;
+                                     * we'll get a PACKAGE_REMOVED later and clean up then.
+                                     *
+                                     * RemoteException can't actually happen; the package manager is
+                                     * running in this same process.
+                                     */
+                                }
+                                break;
+                            }
+                        }
+                        if (DEBUG) {
+                            Slog.d(TAG, "Something in " + pkgName
+                                    + " changed. Reevaluating controller states.");
+                        }
+                        synchronized (mLock) {
+                            for (int c = mControllers.size() - 1; c >= 0; --c) {
+                                mControllers.get(c).reevaluateStateLocked(pkgUid);
+                            }
+                        }
+                    }
+                } else {
+                    Slog.w(TAG, "PACKAGE_CHANGED for " + pkgName + " / uid " + pkgUid);
+                }
+            } else if (Intent.ACTION_PACKAGE_REMOVED.equals(action)) {
+                // If this is an outright uninstall rather than the first half of an
+                // app update sequence, cancel the jobs associated with the app.
+                if (!intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) {
+                    int uidRemoved = intent.getIntExtra(Intent.EXTRA_UID, -1);
+                    if (DEBUG) {
+                        Slog.d(TAG, "Removing jobs for uid: " + uidRemoved);
+                    }
+                    cancelJobsForPackageAndUid(pkgName, uidRemoved, "app uninstalled");
+                    synchronized (mLock) {
+                        for (int c = 0; c < mControllers.size(); ++c) {
+                            mControllers.get(c).onAppRemovedLocked(pkgName, pkgUid);
+                        }
+                    }
+                }
+            } else if (Intent.ACTION_USER_REMOVED.equals(action)) {
+                final int userId = intent.getIntExtra(Intent.EXTRA_USER_HANDLE, 0);
+                if (DEBUG) {
+                    Slog.d(TAG, "Removing jobs for user: " + userId);
+                }
+                cancelJobsForUser(userId);
+                synchronized (mLock) {
+                    for (int c = 0; c < mControllers.size(); ++c) {
+                        mControllers.get(c).onUserRemovedLocked(userId);
+                    }
+                }
+            } else if (Intent.ACTION_QUERY_PACKAGE_RESTART.equals(action)) {
+                // Has this package scheduled any jobs, such that we will take action
+                // if it were to be force-stopped?
+                if (pkgUid != -1) {
+                    List<JobStatus> jobsForUid;
+                    synchronized (mLock) {
+                        jobsForUid = mJobs.getJobsByUid(pkgUid);
+                    }
+                    for (int i = jobsForUid.size() - 1; i >= 0; i--) {
+                        if (jobsForUid.get(i).getSourcePackageName().equals(pkgName)) {
+                            if (DEBUG) {
+                                Slog.d(TAG, "Restart query: package " + pkgName + " at uid "
+                                        + pkgUid + " has jobs");
+                            }
+                            setResultCode(Activity.RESULT_OK);
+                            break;
+                        }
+                    }
+                }
+            } else if (Intent.ACTION_PACKAGE_RESTARTED.equals(action)) {
+                // possible force-stop
+                if (pkgUid != -1) {
+                    if (DEBUG) {
+                        Slog.d(TAG, "Removing jobs for pkg " + pkgName + " at uid " + pkgUid);
+                    }
+                    cancelJobsForPackageAndUid(pkgName, pkgUid, "app force stopped");
+                }
+            }
+        }
+    };
+
+    private String getPackageName(Intent intent) {
+        Uri uri = intent.getData();
+        String pkg = uri != null ? uri.getSchemeSpecificPart() : null;
+        return pkg;
+    }
+
+    final private IUidObserver mUidObserver = new IUidObserver.Stub() {
+        @Override public void onUidStateChanged(int uid, int procState, long procStateSeq) {
+            mHandler.obtainMessage(MSG_UID_STATE_CHANGED, uid, procState).sendToTarget();
+        }
+
+        @Override public void onUidGone(int uid, boolean disabled) {
+            mHandler.obtainMessage(MSG_UID_GONE, uid, disabled ? 1 : 0).sendToTarget();
+        }
+
+        @Override public void onUidActive(int uid) throws RemoteException {
+            mHandler.obtainMessage(MSG_UID_ACTIVE, uid, 0).sendToTarget();
+        }
+
+        @Override public void onUidIdle(int uid, boolean disabled) {
+            mHandler.obtainMessage(MSG_UID_IDLE, uid, disabled ? 1 : 0).sendToTarget();
+        }
+
+        @Override public void onUidCachedChanged(int uid, boolean cached) {
+        }
+    };
+
+    public Context getTestableContext() {
+        return getContext();
+    }
+
+    public Object getLock() {
+        return mLock;
+    }
+
+    public JobStore getJobStore() {
+        return mJobs;
+    }
+
+    public Constants getConstants() {
+        return mConstants;
+    }
+
+    public boolean isChainedAttributionEnabled() {
+        return WorkSource.isChainedBatteryAttributionEnabled(getContext());
+    }
+
+    @Override
+    public void onStartUser(int userHandle) {
+        synchronized (mLock) {
+            mStartedUsers = ArrayUtils.appendInt(mStartedUsers, userHandle);
+        }
+        // Let's kick any outstanding jobs for this user.
+        mHandler.obtainMessage(MSG_CHECK_JOB).sendToTarget();
+    }
+
+    @Override
+    public void onUnlockUser(int userHandle) {
+        // Let's kick any outstanding jobs for this user.
+        mHandler.obtainMessage(MSG_CHECK_JOB).sendToTarget();
+    }
+
+    @Override
+    public void onStopUser(int userHandle) {
+        synchronized (mLock) {
+            mStartedUsers = ArrayUtils.removeInt(mStartedUsers, userHandle);
+        }
+    }
+
+    /**
+     * Return whether an UID is active or idle.
+     */
+    private boolean isUidActive(int uid) {
+        return mAppStateTracker.isUidActiveSynced(uid);
+    }
+
+    private final Predicate<Integer> mIsUidActivePredicate = this::isUidActive;
+
+    public int scheduleAsPackage(JobInfo job, JobWorkItem work, int uId, String packageName,
+            int userId, String tag) {
+        try {
+            if (ActivityManager.getService().isAppStartModeDisabled(uId,
+                    job.getService().getPackageName())) {
+                Slog.w(TAG, "Not scheduling job " + uId + ":" + job.toString()
+                        + " -- package not allowed to start");
+                return JobScheduler.RESULT_FAILURE;
+            }
+        } catch (RemoteException e) {
+        }
+
+        synchronized (mLock) {
+            final JobStatus toCancel = mJobs.getJobByUidAndJobId(uId, job.getId());
+
+            if (work != null && toCancel != null) {
+                // Fast path: we are adding work to an existing job, and the JobInfo is not
+                // changing.  We can just directly enqueue this work in to the job.
+                if (toCancel.getJob().equals(job)) {
+
+                    toCancel.enqueueWorkLocked(ActivityManager.getService(), work);
+
+                    // If any of work item is enqueued when the source is in the foreground,
+                    // exempt the entire job.
+                    toCancel.maybeAddForegroundExemption(mIsUidActivePredicate);
+
+                    return JobScheduler.RESULT_SUCCESS;
+                }
+            }
+
+            JobStatus jobStatus = JobStatus.createFromJobInfo(job, uId, packageName, userId, tag);
+
+            // Give exemption if the source is in the foreground just now.
+            // Note if it's a sync job, this method is called on the handler so it's not exactly
+            // the state when requestSync() was called, but that should be fine because of the
+            // 1 minute foreground grace period.
+            jobStatus.maybeAddForegroundExemption(mIsUidActivePredicate);
+
+            if (DEBUG) Slog.d(TAG, "SCHEDULE: " + jobStatus.toShortString());
+            // Jobs on behalf of others don't apply to the per-app job cap
+            if (ENFORCE_MAX_JOBS && packageName == null) {
+                if (mJobs.countJobsForUid(uId) > MAX_JOBS_PER_APP) {
+                    Slog.w(TAG, "Too many jobs for uid " + uId);
+                    throw new IllegalStateException("Apps may not schedule more than "
+                                + MAX_JOBS_PER_APP + " distinct jobs");
+                }
+            }
+
+            // This may throw a SecurityException.
+            jobStatus.prepareLocked(ActivityManager.getService());
+
+            if (work != null) {
+                // If work has been supplied, enqueue it into the new job.
+                jobStatus.enqueueWorkLocked(ActivityManager.getService(), work);
+            }
+
+            if (toCancel != null) {
+                // Implicitly replaces the existing job record with the new instance
+                cancelJobImplLocked(toCancel, jobStatus, "job rescheduled by app");
+            } else {
+                startTrackingJobLocked(jobStatus, null);
+            }
+            StatsLog.write_non_chained(StatsLog.SCHEDULED_JOB_STATE_CHANGED,
+                    uId, null, jobStatus.getBatteryName(),
+                    StatsLog.SCHEDULED_JOB_STATE_CHANGED__STATE__SCHEDULED,
+                    JobProtoEnums.STOP_REASON_CANCELLED, jobStatus.getStandbyBucket(),
+                    jobStatus.getJobId());
+
+            // If the job is immediately ready to run, then we can just immediately
+            // put it in the pending list and try to schedule it.  This is especially
+            // important for jobs with a 0 deadline constraint, since they will happen a fair
+            // amount, we want to handle them as quickly as possible, and semantically we want to
+            // make sure we have started holding the wake lock for the job before returning to
+            // the caller.
+            // If the job is not yet ready to run, there is nothing more to do -- we are
+            // now just waiting for one of its controllers to change state and schedule
+            // the job appropriately.
+            if (isReadyToBeExecutedLocked(jobStatus)) {
+                // This is a new job, we can just immediately put it on the pending
+                // list and try to run it.
+                mJobPackageTracker.notePending(jobStatus);
+                addOrderedItem(mPendingJobs, jobStatus, mEnqueueTimeComparator);
+                maybeRunPendingJobsLocked();
+            } else {
+                evaluateControllerStatesLocked(jobStatus);
+            }
+        }
+        return JobScheduler.RESULT_SUCCESS;
+    }
+
+    public List<JobInfo> getPendingJobs(int uid) {
+        synchronized (mLock) {
+            List<JobStatus> jobs = mJobs.getJobsByUid(uid);
+            ArrayList<JobInfo> outList = new ArrayList<JobInfo>(jobs.size());
+            for (int i = jobs.size() - 1; i >= 0; i--) {
+                JobStatus job = jobs.get(i);
+                outList.add(job.getJob());
+            }
+            return outList;
+        }
+    }
+
+    public JobInfo getPendingJob(int uid, int jobId) {
+        synchronized (mLock) {
+            List<JobStatus> jobs = mJobs.getJobsByUid(uid);
+            for (int i = jobs.size() - 1; i >= 0; i--) {
+                JobStatus job = jobs.get(i);
+                if (job.getJobId() == jobId) {
+                    return job.getJob();
+                }
+            }
+            return null;
+        }
+    }
+
+    void cancelJobsForUser(int userHandle) {
+        synchronized (mLock) {
+            final List<JobStatus> jobsForUser = mJobs.getJobsByUser(userHandle);
+            for (int i=0; i<jobsForUser.size(); i++) {
+                JobStatus toRemove = jobsForUser.get(i);
+                cancelJobImplLocked(toRemove, null, "user removed");
+            }
+        }
+    }
+
+    private void cancelJobsForNonExistentUsers() {
+        UserManagerInternal umi = LocalServices.getService(UserManagerInternal.class);
+        synchronized (mLock) {
+            mJobs.removeJobsOfNonUsers(umi.getUserIds());
+        }
+    }
+
+    void cancelJobsForPackageAndUid(String pkgName, int uid, String reason) {
+        if ("android".equals(pkgName)) {
+            Slog.wtfStack(TAG, "Can't cancel all jobs for system package");
+            return;
+        }
+        synchronized (mLock) {
+            final List<JobStatus> jobsForUid = mJobs.getJobsByUid(uid);
+            for (int i = jobsForUid.size() - 1; i >= 0; i--) {
+                final JobStatus job = jobsForUid.get(i);
+                if (job.getSourcePackageName().equals(pkgName)) {
+                    cancelJobImplLocked(job, null, reason);
+                }
+            }
+        }
+    }
+
+    /**
+     * Entry point from client to cancel all jobs originating from their uid.
+     * This will remove the job from the master list, and cancel the job if it was staged for
+     * execution or being executed.
+     * @param uid Uid to check against for removal of a job.
+     *
+     */
+    public boolean cancelJobsForUid(int uid, String reason) {
+        if (uid == Process.SYSTEM_UID) {
+            Slog.wtfStack(TAG, "Can't cancel all jobs for system uid");
+            return false;
+        }
+
+        boolean jobsCanceled = false;
+        synchronized (mLock) {
+            final List<JobStatus> jobsForUid = mJobs.getJobsByUid(uid);
+            for (int i=0; i<jobsForUid.size(); i++) {
+                JobStatus toRemove = jobsForUid.get(i);
+                cancelJobImplLocked(toRemove, null, reason);
+                jobsCanceled = true;
+            }
+        }
+        return jobsCanceled;
+    }
+
+    /**
+     * Entry point from client to cancel the job corresponding to the jobId provided.
+     * This will remove the job from the master list, and cancel the job if it was staged for
+     * execution or being executed.
+     * @param uid Uid of the calling client.
+     * @param jobId Id of the job, provided at schedule-time.
+     */
+    public boolean cancelJob(int uid, int jobId, int callingUid) {
+        JobStatus toCancel;
+        synchronized (mLock) {
+            toCancel = mJobs.getJobByUidAndJobId(uid, jobId);
+            if (toCancel != null) {
+                cancelJobImplLocked(toCancel, null,
+                        "cancel() called by app, callingUid=" + callingUid
+                        + " uid=" + uid + " jobId=" + jobId);
+            }
+            return (toCancel != null);
+        }
+    }
+
+    /**
+     * Cancel the given job, stopping it if it's currently executing.  If {@code incomingJob}
+     * is null, the cancelled job is removed outright from the system.  If
+     * {@code incomingJob} is non-null, it replaces {@code cancelled} in the store of
+     * currently scheduled jobs.
+     */
+    private void cancelJobImplLocked(JobStatus cancelled, JobStatus incomingJob, String reason) {
+        if (DEBUG) Slog.d(TAG, "CANCEL: " + cancelled.toShortString());
+        cancelled.unprepareLocked(ActivityManager.getService());
+        stopTrackingJobLocked(cancelled, incomingJob, true /* writeBack */);
+        // Remove from pending queue.
+        if (mPendingJobs.remove(cancelled)) {
+            mJobPackageTracker.noteNonpending(cancelled);
+        }
+        // Cancel if running.
+        stopJobOnServiceContextLocked(cancelled, JobParameters.REASON_CANCELED, reason);
+        // If this is a replacement, bring in the new version of the job
+        if (incomingJob != null) {
+            if (DEBUG) Slog.i(TAG, "Tracking replacement job " + incomingJob.toShortString());
+            startTrackingJobLocked(incomingJob, cancelled);
+        }
+        reportActiveLocked();
+    }
+
+    void updateUidState(int uid, int procState) {
+        synchronized (mLock) {
+            if (procState == ActivityManager.PROCESS_STATE_TOP) {
+                // Only use this if we are exactly the top app.  All others can live
+                // with just the foreground priority.  This means that persistent processes
+                // can never be the top app priority...  that is fine.
+                mUidPriorityOverride.put(uid, JobInfo.PRIORITY_TOP_APP);
+            } else if (procState <= ActivityManager.PROCESS_STATE_FOREGROUND_SERVICE) {
+                mUidPriorityOverride.put(uid, JobInfo.PRIORITY_FOREGROUND_SERVICE);
+            } else if (procState <= ActivityManager.PROCESS_STATE_BOUND_FOREGROUND_SERVICE) {
+                mUidPriorityOverride.put(uid, JobInfo.PRIORITY_BOUND_FOREGROUND_SERVICE);
+            } else {
+                mUidPriorityOverride.delete(uid);
+            }
+        }
+    }
+
+    @Override
+    public void onDeviceIdleStateChanged(boolean deviceIdle) {
+        synchronized (mLock) {
+            if (DEBUG) {
+                Slog.d(TAG, "Doze state changed: " + deviceIdle);
+            }
+            if (deviceIdle) {
+                // When becoming idle, make sure no jobs are actively running,
+                // except those using the idle exemption flag.
+                for (int i=0; i<mActiveServices.size(); i++) {
+                    JobServiceContext jsc = mActiveServices.get(i);
+                    final JobStatus executing = jsc.getRunningJobLocked();
+                    if (executing != null
+                            && (executing.getFlags() & JobInfo.FLAG_WILL_BE_FOREGROUND) == 0) {
+                        jsc.cancelExecutingJobLocked(JobParameters.REASON_DEVICE_IDLE,
+                                "cancelled due to doze");
+                    }
+                }
+            } else {
+                // When coming out of idle, allow thing to start back up.
+                if (mReadyToRock) {
+                    if (mLocalDeviceIdleController != null) {
+                        if (!mReportedActive) {
+                            mReportedActive = true;
+                            mLocalDeviceIdleController.setJobsActive(true);
+                        }
+                    }
+                    mHandler.obtainMessage(MSG_CHECK_JOB).sendToTarget();
+                }
+            }
+        }
+    }
+
+    void reportActiveLocked() {
+        // active is true if pending queue contains jobs OR some job is running.
+        boolean active = mPendingJobs.size() > 0;
+        if (mPendingJobs.size() <= 0) {
+            for (int i=0; i<mActiveServices.size(); i++) {
+                final JobServiceContext jsc = mActiveServices.get(i);
+                final JobStatus job = jsc.getRunningJobLocked();
+                if (job != null
+                        && (job.getJob().getFlags() & JobInfo.FLAG_WILL_BE_FOREGROUND) == 0
+                        && !job.dozeWhitelisted
+                        && !job.uidActive) {
+                    // We will report active if we have a job running and it is not an exception
+                    // due to being in the foreground or whitelisted.
+                    active = true;
+                    break;
+                }
+            }
+        }
+
+        if (mReportedActive != active) {
+            mReportedActive = active;
+            if (mLocalDeviceIdleController != null) {
+                mLocalDeviceIdleController.setJobsActive(active);
+            }
+        }
+    }
+
+    void reportAppUsage(String packageName, int userId) {
+        // This app just transitioned into interactive use or near equivalent, so we should
+        // take a look at its job state for feedback purposes.
+    }
+
+    /**
+     * Initializes the system service.
+     * <p>
+     * Subclasses must define a single argument constructor that accepts the context
+     * and passes it to super.
+     * </p>
+     *
+     * @param context The system server context.
+     */
+    public JobSchedulerService(Context context) {
+        super(context);
+
+        mLocalPM = LocalServices.getService(PackageManagerInternal.class);
+        mActivityManagerInternal = Preconditions.checkNotNull(
+                LocalServices.getService(ActivityManagerInternal.class));
+
+        mHandler = new JobHandler(context.getMainLooper());
+        mConstants = new Constants();
+        mConstantsObserver = new ConstantsObserver(mHandler);
+        mJobSchedulerStub = new JobSchedulerStub();
+
+        mConcurrencyManager = new JobConcurrencyManager(this);
+
+        // Set up the app standby bucketing tracker
+        mStandbyTracker = new StandbyTracker();
+        mUsageStats = LocalServices.getService(UsageStatsManagerInternal.class);
+        mUsageStats.addAppIdleStateChangeListener(mStandbyTracker);
+
+        // The job store needs to call back
+        publishLocalService(JobSchedulerInternal.class, new LocalService());
+
+        // Initialize the job store and set up any persisted jobs
+        mJobs = JobStore.initAndGet(this);
+
+        // Create the controllers.
+        mControllers = new ArrayList<StateController>();
+        mControllers.add(new ConnectivityController(this));
+        mControllers.add(new TimeController(this));
+        mControllers.add(new IdleController(this));
+        mBatteryController = new BatteryController(this);
+        mControllers.add(mBatteryController);
+        mStorageController = new StorageController(this);
+        mControllers.add(mStorageController);
+        mControllers.add(new BackgroundJobsController(this));
+        mControllers.add(new ContentObserverController(this));
+        mDeviceIdleJobsController = new DeviceIdleJobsController(this);
+        mControllers.add(mDeviceIdleJobsController);
+        mQuotaController = new QuotaController(this);
+        mControllers.add(mQuotaController);
+
+        // If the job store determined that it can't yet reschedule persisted jobs,
+        // we need to start watching the clock.
+        if (!mJobs.jobTimesInflatedValid()) {
+            Slog.w(TAG, "!!! RTC not yet good; tracking time updates for job scheduling");
+            context.registerReceiver(mTimeSetReceiver, new IntentFilter(Intent.ACTION_TIME_CHANGED));
+        }
+    }
+
+    private final BroadcastReceiver mTimeSetReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (Intent.ACTION_TIME_CHANGED.equals(intent.getAction())) {
+                // When we reach clock sanity, recalculate the temporal windows
+                // of all affected jobs.
+                if (mJobs.clockNowValidToInflate(sSystemClock.millis())) {
+                    Slog.i(TAG, "RTC now valid; recalculating persisted job windows");
+
+                    // We've done our job now, so stop watching the time.
+                    context.unregisterReceiver(this);
+
+                    // And kick off the work to update the affected jobs, using a secondary
+                    // thread instead of chugging away here on the main looper thread.
+                    FgThread.getHandler().post(mJobTimeUpdater);
+                }
+            }
+        }
+    };
+
+    private final Runnable mJobTimeUpdater = () -> {
+        final ArrayList<JobStatus> toRemove = new ArrayList<>();
+        final ArrayList<JobStatus> toAdd = new ArrayList<>();
+        synchronized (mLock) {
+            // Note: we intentionally both look up the existing affected jobs and replace them
+            // with recalculated ones inside the same lock lifetime.
+            getJobStore().getRtcCorrectedJobsLocked(toAdd, toRemove);
+
+            // Now, at each position [i], we have both the existing JobStatus
+            // and the one that replaces it.
+            final int N = toAdd.size();
+            for (int i = 0; i < N; i++) {
+                final JobStatus oldJob = toRemove.get(i);
+                final JobStatus newJob = toAdd.get(i);
+                if (DEBUG) {
+                    Slog.v(TAG, "  replacing " + oldJob + " with " + newJob);
+                }
+                cancelJobImplLocked(oldJob, newJob, "deferred rtc calculation");
+            }
+        }
+    };
+
+    @Override
+    public void onStart() {
+        publishBinderService(Context.JOB_SCHEDULER_SERVICE, mJobSchedulerStub);
+    }
+
+    @Override
+    public void onBootPhase(int phase) {
+        if (PHASE_SYSTEM_SERVICES_READY == phase) {
+            mConstantsObserver.start(getContext().getContentResolver());
+            for (StateController controller : mControllers) {
+                controller.onSystemServicesReady();
+            }
+
+            mAppStateTracker = Preconditions.checkNotNull(
+                    LocalServices.getService(AppStateTracker.class));
+            if (mConstants.USE_HEARTBEATS) {
+                setNextHeartbeatAlarm();
+            }
+
+            // Register br for package removals and user removals.
+            final IntentFilter filter = new IntentFilter();
+            filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
+            filter.addAction(Intent.ACTION_PACKAGE_CHANGED);
+            filter.addAction(Intent.ACTION_PACKAGE_RESTARTED);
+            filter.addAction(Intent.ACTION_QUERY_PACKAGE_RESTART);
+            filter.addDataScheme("package");
+            getContext().registerReceiverAsUser(
+                    mBroadcastReceiver, UserHandle.ALL, filter, null, null);
+            final IntentFilter userFilter = new IntentFilter(Intent.ACTION_USER_REMOVED);
+            getContext().registerReceiverAsUser(
+                    mBroadcastReceiver, UserHandle.ALL, userFilter, null, null);
+            try {
+                ActivityManager.getService().registerUidObserver(mUidObserver,
+                        ActivityManager.UID_OBSERVER_PROCSTATE | ActivityManager.UID_OBSERVER_GONE
+                        | ActivityManager.UID_OBSERVER_IDLE | ActivityManager.UID_OBSERVER_ACTIVE,
+                        ActivityManager.PROCESS_STATE_UNKNOWN, null);
+            } catch (RemoteException e) {
+                // ignored; both services live in system_server
+            }
+
+            mConcurrencyManager.onSystemReady();
+
+            // Remove any jobs that are not associated with any of the current users.
+            cancelJobsForNonExistentUsers();
+            // Register thermal callback
+            mThermalService = IThermalService.Stub.asInterface(
+                    ServiceManager.getService(Context.THERMAL_SERVICE));
+            if (mThermalService != null) {
+                try {
+                    mThermalService.registerThermalStatusListener(new ThermalStatusListener());
+                } catch (RemoteException e) {
+                    Slog.e(TAG, "Failed to register thermal callback.", e);
+                }
+            }
+        } else if (phase == PHASE_THIRD_PARTY_APPS_CAN_START) {
+            synchronized (mLock) {
+                // Let's go!
+                mReadyToRock = true;
+                mBatteryStats = IBatteryStats.Stub.asInterface(ServiceManager.getService(
+                        BatteryStats.SERVICE_NAME));
+                mLocalDeviceIdleController
+                        = LocalServices.getService(DeviceIdleController.LocalService.class);
+                // Create the "runners".
+                for (int i = 0; i < MAX_JOB_CONTEXTS_COUNT; i++) {
+                    mActiveServices.add(
+                            new JobServiceContext(this, mBatteryStats, mJobPackageTracker,
+                                    getContext().getMainLooper()));
+                }
+                // Attach jobs to their controllers.
+                mJobs.forEachJob((job) -> {
+                    for (int controller = 0; controller < mControllers.size(); controller++) {
+                        final StateController sc = mControllers.get(controller);
+                        sc.maybeStartTrackingJobLocked(job, null);
+                    }
+                });
+                // GO GO GO!
+                mHandler.obtainMessage(MSG_CHECK_JOB).sendToTarget();
+            }
+        }
+    }
+
+    /**
+     * Called when we have a job status object that we need to insert in our
+     * {@link com.android.server.job.JobStore}, and make sure all the relevant controllers know
+     * about.
+     */
+    private void startTrackingJobLocked(JobStatus jobStatus, JobStatus lastJob) {
+        if (!jobStatus.isPreparedLocked()) {
+            Slog.wtf(TAG, "Not yet prepared when started tracking: " + jobStatus);
+        }
+        jobStatus.enqueueTime = sElapsedRealtimeClock.millis();
+        final boolean update = mJobs.add(jobStatus);
+        if (mReadyToRock) {
+            for (int i = 0; i < mControllers.size(); i++) {
+                StateController controller = mControllers.get(i);
+                if (update) {
+                    controller.maybeStopTrackingJobLocked(jobStatus, null, true);
+                }
+                controller.maybeStartTrackingJobLocked(jobStatus, lastJob);
+            }
+        }
+    }
+
+    /**
+     * Called when we want to remove a JobStatus object that we've finished executing.
+     * @return true if the job was removed.
+     */
+    private boolean stopTrackingJobLocked(JobStatus jobStatus, JobStatus incomingJob,
+            boolean removeFromPersisted) {
+        // Deal with any remaining work items in the old job.
+        jobStatus.stopTrackingJobLocked(ActivityManager.getService(), incomingJob);
+
+        // Remove from store as well as controllers.
+        final boolean removed = mJobs.remove(jobStatus, removeFromPersisted);
+        if (removed && mReadyToRock) {
+            for (int i=0; i<mControllers.size(); i++) {
+                StateController controller = mControllers.get(i);
+                controller.maybeStopTrackingJobLocked(jobStatus, incomingJob, false);
+            }
+        }
+        return removed;
+    }
+
+    private boolean stopJobOnServiceContextLocked(JobStatus job, int reason, String debugReason) {
+        for (int i=0; i<mActiveServices.size(); i++) {
+            JobServiceContext jsc = mActiveServices.get(i);
+            final JobStatus executing = jsc.getRunningJobLocked();
+            if (executing != null && executing.matches(job.getUid(), job.getJobId())) {
+                jsc.cancelExecutingJobLocked(reason, debugReason);
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * @param job JobStatus we are querying against.
+     * @return Whether or not the job represented by the status object is currently being run or
+     * is pending.
+     */
+    private boolean isCurrentlyActiveLocked(JobStatus job) {
+        for (int i=0; i<mActiveServices.size(); i++) {
+            JobServiceContext serviceContext = mActiveServices.get(i);
+            final JobStatus running = serviceContext.getRunningJobLocked();
+            if (running != null && running.matches(job.getUid(), job.getJobId())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    void noteJobsPending(List<JobStatus> jobs) {
+        for (int i = jobs.size() - 1; i >= 0; i--) {
+            JobStatus job = jobs.get(i);
+            mJobPackageTracker.notePending(job);
+        }
+    }
+
+    void noteJobsNonpending(List<JobStatus> jobs) {
+        for (int i = jobs.size() - 1; i >= 0; i--) {
+            JobStatus job = jobs.get(i);
+            mJobPackageTracker.noteNonpending(job);
+        }
+    }
+
+    /**
+     * Reschedules the given job based on the job's backoff policy. It doesn't make sense to
+     * specify an override deadline on a failed job (the failed job will run even though it's not
+     * ready), so we reschedule it with {@link JobStatus#NO_LATEST_RUNTIME}, but specify that any
+     * ready job with {@link JobStatus#getNumFailures()} > 0 will be executed.
+     *
+     * @param failureToReschedule Provided job status that we will reschedule.
+     * @return A newly instantiated JobStatus with the same constraints as the last job except
+     * with adjusted timing constraints.
+     *
+     * @see #maybeQueueReadyJobsForExecutionLocked
+     */
+    @VisibleForTesting
+    JobStatus getRescheduleJobForFailureLocked(JobStatus failureToReschedule) {
+        final long elapsedNowMillis = sElapsedRealtimeClock.millis();
+        final JobInfo job = failureToReschedule.getJob();
+
+        final long initialBackoffMillis = job.getInitialBackoffMillis();
+        final int backoffAttempts = failureToReschedule.getNumFailures() + 1;
+        long delayMillis;
+
+        if (failureToReschedule.hasWorkLocked()) {
+            if (backoffAttempts > mConstants.MAX_WORK_RESCHEDULE_COUNT) {
+                Slog.w(TAG, "Not rescheduling " + failureToReschedule + ": attempt #"
+                        + backoffAttempts + " > work limit "
+                        + mConstants.MAX_STANDARD_RESCHEDULE_COUNT);
+                return null;
+            }
+        } else if (backoffAttempts > mConstants.MAX_STANDARD_RESCHEDULE_COUNT) {
+            Slog.w(TAG, "Not rescheduling " + failureToReschedule + ": attempt #"
+                    + backoffAttempts + " > std limit " + mConstants.MAX_STANDARD_RESCHEDULE_COUNT);
+            return null;
+        }
+
+        switch (job.getBackoffPolicy()) {
+            case JobInfo.BACKOFF_POLICY_LINEAR: {
+                long backoff = initialBackoffMillis;
+                if (backoff < mConstants.MIN_LINEAR_BACKOFF_TIME) {
+                    backoff = mConstants.MIN_LINEAR_BACKOFF_TIME;
+                }
+                delayMillis = backoff * backoffAttempts;
+            } break;
+            default:
+                if (DEBUG) {
+                    Slog.v(TAG, "Unrecognised back-off policy, defaulting to exponential.");
+                }
+            case JobInfo.BACKOFF_POLICY_EXPONENTIAL: {
+                long backoff = initialBackoffMillis;
+                if (backoff < mConstants.MIN_EXP_BACKOFF_TIME) {
+                    backoff = mConstants.MIN_EXP_BACKOFF_TIME;
+                }
+                delayMillis = (long) Math.scalb(backoff, backoffAttempts - 1);
+            } break;
+        }
+        delayMillis =
+                Math.min(delayMillis, JobInfo.MAX_BACKOFF_DELAY_MILLIS);
+        JobStatus newJob = new JobStatus(failureToReschedule, getCurrentHeartbeat(),
+                elapsedNowMillis + delayMillis,
+                JobStatus.NO_LATEST_RUNTIME, backoffAttempts,
+                failureToReschedule.getLastSuccessfulRunTime(), sSystemClock.millis());
+        if (job.isPeriodic()) {
+            newJob.setOriginalLatestRunTimeElapsed(
+                    failureToReschedule.getOriginalLatestRunTimeElapsed());
+        }
+        for (int ic=0; ic<mControllers.size(); ic++) {
+            StateController controller = mControllers.get(ic);
+            controller.rescheduleForFailureLocked(newJob, failureToReschedule);
+        }
+        return newJob;
+    }
+
+    /**
+     * Maximum time buffer in which JobScheduler will try to optimize periodic job scheduling. This
+     * does not cause a job's period to be larger than requested (eg: if the requested period is
+     * shorter than this buffer). This is used to put a limit on when JobScheduler will intervene
+     * and try to optimize scheduling if the current job finished less than this amount of time to
+     * the start of the next period
+     */
+    private static final long PERIODIC_JOB_WINDOW_BUFFER = 30 * MINUTE_IN_MILLIS;
+
+    /** The maximum period a periodic job can have. Anything higher will be clamped down to this. */
+    public static final long MAX_ALLOWED_PERIOD_MS = 365 * 24 * 60 * 60 * 1000L;
+
+    /**
+     * Called after a periodic has executed so we can reschedule it. We take the last execution
+     * time of the job to be the time of completion (i.e. the time at which this function is
+     * called).
+     * <p>This could be inaccurate b/c the job can run for as long as
+     * {@link com.android.server.job.JobServiceContext#EXECUTING_TIMESLICE_MILLIS}, but will lead
+     * to underscheduling at least, rather than if we had taken the last execution time to be the
+     * start of the execution.
+     * <p>Unlike a reschedule prior to execution, in this case we advance the next-heartbeat
+     * tracking as though the job were newly-scheduled.
+     * @return A new job representing the execution criteria for this instantiation of the
+     * recurring job.
+     */
+    @VisibleForTesting
+    JobStatus getRescheduleJobForPeriodic(JobStatus periodicToReschedule) {
+        final long elapsedNow = sElapsedRealtimeClock.millis();
+        final long newLatestRuntimeElapsed;
+        // Make sure period is in the interval [min_possible_period, max_possible_period].
+        final long period = Math.max(JobInfo.getMinPeriodMillis(),
+                Math.min(MAX_ALLOWED_PERIOD_MS, periodicToReschedule.getJob().getIntervalMillis()));
+        // Make sure flex is in the interval [min_possible_flex, period].
+        final long flex = Math.max(JobInfo.getMinFlexMillis(),
+                Math.min(period, periodicToReschedule.getJob().getFlexMillis()));
+        long rescheduleBuffer = 0;
+
+        long olrte = periodicToReschedule.getOriginalLatestRunTimeElapsed();
+        if (olrte < 0 || olrte == JobStatus.NO_LATEST_RUNTIME) {
+            Slog.wtf(TAG, "Invalid periodic job original latest run time: " + olrte);
+            olrte = elapsedNow;
+        }
+        final long latestRunTimeElapsed = olrte;
+
+        final long diffMs = Math.abs(elapsedNow - latestRunTimeElapsed);
+        if (elapsedNow > latestRunTimeElapsed) {
+            // The job ran past its expected run window. Have it count towards the current window
+            // and schedule a new job for the next window.
+            if (DEBUG) {
+                Slog.i(TAG, "Periodic job ran after its intended window.");
+            }
+            long numSkippedWindows = (diffMs / period) + 1; // +1 to include original window
+            if (period != flex && diffMs > Math.min(PERIODIC_JOB_WINDOW_BUFFER,
+                    (period - flex) / 2)) {
+                if (DEBUG) {
+                    Slog.d(TAG, "Custom flex job ran too close to next window.");
+                }
+                // For custom flex periods, if the job was run too close to the next window,
+                // skip the next window and schedule for the following one.
+                numSkippedWindows += 1;
+            }
+            newLatestRuntimeElapsed = latestRunTimeElapsed + (period * numSkippedWindows);
+        } else {
+            newLatestRuntimeElapsed = latestRunTimeElapsed + period;
+            if (diffMs < PERIODIC_JOB_WINDOW_BUFFER && diffMs < period / 6) {
+                // Add a little buffer to the start of the next window so the job doesn't run
+                // too soon after this completed one.
+                rescheduleBuffer = Math.min(PERIODIC_JOB_WINDOW_BUFFER, period / 6 - diffMs);
+            }
+        }
+
+        if (newLatestRuntimeElapsed < elapsedNow) {
+            Slog.wtf(TAG, "Rescheduling calculated latest runtime in the past: "
+                    + newLatestRuntimeElapsed);
+            return new JobStatus(periodicToReschedule, getCurrentHeartbeat(),
+                    elapsedNow + period - flex, elapsedNow + period,
+                    0 /* backoffAttempt */,
+                    sSystemClock.millis() /* lastSuccessfulRunTime */,
+                    periodicToReschedule.getLastFailedRunTime());
+        }
+
+        final long newEarliestRunTimeElapsed = newLatestRuntimeElapsed
+                - Math.min(flex, period - rescheduleBuffer);
+
+        if (DEBUG) {
+            Slog.v(TAG, "Rescheduling executed periodic. New execution window [" +
+                    newEarliestRunTimeElapsed / 1000 + ", " + newLatestRuntimeElapsed / 1000
+                    + "]s");
+        }
+        return new JobStatus(periodicToReschedule, getCurrentHeartbeat(),
+                newEarliestRunTimeElapsed, newLatestRuntimeElapsed,
+                0 /* backoffAttempt */,
+                sSystemClock.millis() /* lastSuccessfulRunTime */,
+                periodicToReschedule.getLastFailedRunTime());
+    }
+
+    /*
+     * We default to "long enough ago that every bucket's jobs are immediately runnable" to
+     * avoid starvation of apps in uncommon-use buckets that might arise from repeated
+     * reboot behavior.
+     */
+    long heartbeatWhenJobsLastRun(String packageName, final @UserIdInt int userId) {
+        // The furthest back in pre-boot time that we need to bother with
+        long heartbeat = -mConstants.STANDBY_BEATS[RARE_INDEX];
+        boolean cacheHit = false;
+        synchronized (mLock) {
+            HashMap<String, Long> jobPackages = mLastJobHeartbeats.get(userId);
+            if (jobPackages != null) {
+                long cachedValue = jobPackages.getOrDefault(packageName, Long.MAX_VALUE);
+                if (cachedValue < Long.MAX_VALUE) {
+                    cacheHit = true;
+                    heartbeat = cachedValue;
+                }
+            }
+            if (!cacheHit) {
+                // We haven't seen it yet; ask usage stats about it
+                final long timeSinceJob = mUsageStats.getTimeSinceLastJobRun(packageName, userId);
+                if (timeSinceJob < Long.MAX_VALUE) {
+                    // Usage stats knows about it from before, so calculate back from that
+                    // and go from there.
+                    heartbeat = mHeartbeat - (timeSinceJob / mConstants.STANDBY_HEARTBEAT_TIME);
+                }
+                // If usage stats returned its "not found" MAX_VALUE, we still have the
+                // negative default 'heartbeat' value we established above
+                setLastJobHeartbeatLocked(packageName, userId, heartbeat);
+            }
+        }
+        if (DEBUG_STANDBY) {
+            Slog.v(TAG, "Last job heartbeat " + heartbeat + " for "
+                    + packageName + "/" + userId);
+        }
+        return heartbeat;
+    }
+
+    long heartbeatWhenJobsLastRun(JobStatus job) {
+        return heartbeatWhenJobsLastRun(job.getSourcePackageName(), job.getSourceUserId());
+    }
+
+    void setLastJobHeartbeatLocked(String packageName, int userId, long heartbeat) {
+        HashMap<String, Long> jobPackages = mLastJobHeartbeats.get(userId);
+        if (jobPackages == null) {
+            jobPackages = new HashMap<>();
+            mLastJobHeartbeats.put(userId, jobPackages);
+        }
+        jobPackages.put(packageName, heartbeat);
+    }
+
+    // JobCompletedListener implementations.
+
+    /**
+     * A job just finished executing. We fetch the
+     * {@link com.android.server.job.controllers.JobStatus} from the store and depending on
+     * whether we want to reschedule we re-add it to the controllers.
+     * @param jobStatus Completed job.
+     * @param needsReschedule Whether the implementing class should reschedule this job.
+     */
+    @Override
+    public void onJobCompletedLocked(JobStatus jobStatus, boolean needsReschedule) {
+        if (DEBUG) {
+            Slog.d(TAG, "Completed " + jobStatus + ", reschedule=" + needsReschedule);
+        }
+
+        // If the job wants to be rescheduled, we first need to make the next upcoming
+        // job so we can transfer any appropriate state over from the previous job when
+        // we stop it.
+        final JobStatus rescheduledJob = needsReschedule
+                ? getRescheduleJobForFailureLocked(jobStatus) : null;
+
+        // Do not write back immediately if this is a periodic job. The job may get lost if system
+        // shuts down before it is added back.
+        if (!stopTrackingJobLocked(jobStatus, rescheduledJob, !jobStatus.getJob().isPeriodic())) {
+            if (DEBUG) {
+                Slog.d(TAG, "Could not find job to remove. Was job removed while executing?");
+            }
+            // We still want to check for jobs to execute, because this job may have
+            // scheduled a new job under the same job id, and now we can run it.
+            mHandler.obtainMessage(MSG_CHECK_JOB_GREEDY).sendToTarget();
+            return;
+        }
+
+        if (rescheduledJob != null) {
+            try {
+                rescheduledJob.prepareLocked(ActivityManager.getService());
+            } catch (SecurityException e) {
+                Slog.w(TAG, "Unable to regrant job permissions for " + rescheduledJob);
+            }
+            startTrackingJobLocked(rescheduledJob, jobStatus);
+        } else if (jobStatus.getJob().isPeriodic()) {
+            JobStatus rescheduledPeriodic = getRescheduleJobForPeriodic(jobStatus);
+            try {
+                rescheduledPeriodic.prepareLocked(ActivityManager.getService());
+            } catch (SecurityException e) {
+                Slog.w(TAG, "Unable to regrant job permissions for " + rescheduledPeriodic);
+            }
+            startTrackingJobLocked(rescheduledPeriodic, jobStatus);
+        }
+        jobStatus.unprepareLocked(ActivityManager.getService());
+        reportActiveLocked();
+        mHandler.obtainMessage(MSG_CHECK_JOB_GREEDY).sendToTarget();
+    }
+
+    // StateChangedListener implementations.
+
+    /**
+     * Posts a message to the {@link com.android.server.job.JobSchedulerService.JobHandler} that
+     * some controller's state has changed, so as to run through the list of jobs and start/stop
+     * any that are eligible.
+     */
+    @Override
+    public void onControllerStateChanged() {
+        mHandler.obtainMessage(MSG_CHECK_JOB).sendToTarget();
+    }
+
+    @Override
+    public void onRunJobNow(JobStatus jobStatus) {
+        mHandler.obtainMessage(MSG_JOB_EXPIRED, jobStatus).sendToTarget();
+    }
+
+    final private class JobHandler extends Handler {
+
+        public JobHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message message) {
+            synchronized (mLock) {
+                if (!mReadyToRock) {
+                    return;
+                }
+                switch (message.what) {
+                    case MSG_JOB_EXPIRED: {
+                        JobStatus runNow = (JobStatus) message.obj;
+                        // runNow can be null, which is a controller's way of indicating that its
+                        // state is such that all ready jobs should be run immediately.
+                        if (runNow != null && isReadyToBeExecutedLocked(runNow)) {
+                            mJobPackageTracker.notePending(runNow);
+                            addOrderedItem(mPendingJobs, runNow, mEnqueueTimeComparator);
+                        } else {
+                            queueReadyJobsForExecutionLocked();
+                        }
+                    } break;
+                    case MSG_CHECK_JOB:
+                        if (DEBUG) {
+                            Slog.d(TAG, "MSG_CHECK_JOB");
+                        }
+                        removeMessages(MSG_CHECK_JOB);
+                        if (mReportedActive) {
+                            // if jobs are currently being run, queue all ready jobs for execution.
+                            queueReadyJobsForExecutionLocked();
+                        } else {
+                            // Check the list of jobs and run some of them if we feel inclined.
+                            maybeQueueReadyJobsForExecutionLocked();
+                        }
+                        break;
+                    case MSG_CHECK_JOB_GREEDY:
+                        if (DEBUG) {
+                            Slog.d(TAG, "MSG_CHECK_JOB_GREEDY");
+                        }
+                        queueReadyJobsForExecutionLocked();
+                        break;
+                    case MSG_STOP_JOB:
+                        cancelJobImplLocked((JobStatus) message.obj, null,
+                                "app no longer allowed to run");
+                        break;
+
+                    case MSG_UID_STATE_CHANGED: {
+                        final int uid = message.arg1;
+                        final int procState = message.arg2;
+                        updateUidState(uid, procState);
+                        break;
+                    }
+                    case MSG_UID_GONE: {
+                        final int uid = message.arg1;
+                        final boolean disabled = message.arg2 != 0;
+                        updateUidState(uid, ActivityManager.PROCESS_STATE_CACHED_EMPTY);
+                        if (disabled) {
+                            cancelJobsForUid(uid, "uid gone");
+                        }
+                        synchronized (mLock) {
+                            mDeviceIdleJobsController.setUidActiveLocked(uid, false);
+                        }
+                        break;
+                    }
+                    case MSG_UID_ACTIVE: {
+                        final int uid = message.arg1;
+                        synchronized (mLock) {
+                            mDeviceIdleJobsController.setUidActiveLocked(uid, true);
+                        }
+                        break;
+                    }
+                    case MSG_UID_IDLE: {
+                        final int uid = message.arg1;
+                        final boolean disabled = message.arg2 != 0;
+                        if (disabled) {
+                            cancelJobsForUid(uid, "app uid idle");
+                        }
+                        synchronized (mLock) {
+                            mDeviceIdleJobsController.setUidActiveLocked(uid, false);
+                        }
+                        break;
+                    }
+
+                }
+                maybeRunPendingJobsLocked();
+                // Don't remove JOB_EXPIRED in case one came along while processing the queue.
+            }
+        }
+    }
+
+    private boolean isJobThermalConstrainedLocked(JobStatus job) {
+        return mThermalConstraint && job.hasConnectivityConstraint()
+                && (evaluateJobPriorityLocked(job) < JobInfo.PRIORITY_FOREGROUND_APP);
+    }
+
+    private void stopNonReadyActiveJobsLocked() {
+        for (int i=0; i<mActiveServices.size(); i++) {
+            JobServiceContext serviceContext = mActiveServices.get(i);
+            final JobStatus running = serviceContext.getRunningJobLocked();
+            if (running == null) {
+                continue;
+            }
+            if (!running.isReady()) {
+                serviceContext.cancelExecutingJobLocked(
+                        JobParameters.REASON_CONSTRAINTS_NOT_SATISFIED,
+                        "cancelled due to unsatisfied constraints");
+            } else if (isJobThermalConstrainedLocked(running)) {
+                serviceContext.cancelExecutingJobLocked(
+                        JobParameters.REASON_DEVICE_THERMAL,
+                        "cancelled due to thermal condition");
+            }
+        }
+    }
+
+    /**
+     * Run through list of jobs and execute all possible - at least one is expired so we do
+     * as many as we can.
+     */
+    private void queueReadyJobsForExecutionLocked() {
+        if (DEBUG) {
+            Slog.d(TAG, "queuing all ready jobs for execution:");
+        }
+        noteJobsNonpending(mPendingJobs);
+        mPendingJobs.clear();
+        stopNonReadyActiveJobsLocked();
+        mJobs.forEachJob(mReadyQueueFunctor);
+        mReadyQueueFunctor.postProcess();
+
+        if (DEBUG) {
+            final int queuedJobs = mPendingJobs.size();
+            if (queuedJobs == 0) {
+                Slog.d(TAG, "No jobs pending.");
+            } else {
+                Slog.d(TAG, queuedJobs + " jobs queued.");
+            }
+        }
+    }
+
+    final class ReadyJobQueueFunctor implements Consumer<JobStatus> {
+        final ArrayList<JobStatus> newReadyJobs = new ArrayList<>();
+
+        @Override
+        public void accept(JobStatus job) {
+            if (isReadyToBeExecutedLocked(job)) {
+                if (DEBUG) {
+                    Slog.d(TAG, "    queued " + job.toShortString());
+                }
+                newReadyJobs.add(job);
+            } else {
+                evaluateControllerStatesLocked(job);
+            }
+        }
+
+        public void postProcess() {
+            noteJobsPending(newReadyJobs);
+            mPendingJobs.addAll(newReadyJobs);
+            if (mPendingJobs.size() > 1) {
+                mPendingJobs.sort(mEnqueueTimeComparator);
+            }
+
+            newReadyJobs.clear();
+        }
+    }
+    private final ReadyJobQueueFunctor mReadyQueueFunctor = new ReadyJobQueueFunctor();
+
+    /**
+     * The state of at least one job has changed. Here is where we could enforce various
+     * policies on when we want to execute jobs.
+     */
+    final class MaybeReadyJobQueueFunctor implements Consumer<JobStatus> {
+        int chargingCount;
+        int batteryNotLowCount;
+        int storageNotLowCount;
+        int idleCount;
+        int backoffCount;
+        int connectivityCount;
+        int contentCount;
+        int forceBatchedCount;
+        int unbatchedCount;
+        final List<JobStatus> runnableJobs = new ArrayList<>();
+
+        public MaybeReadyJobQueueFunctor() {
+            reset();
+        }
+
+        // Functor method invoked for each job via JobStore.forEachJob()
+        @Override
+        public void accept(JobStatus job) {
+            if (isReadyToBeExecutedLocked(job)) {
+                try {
+                    if (ActivityManager.getService().isAppStartModeDisabled(job.getUid(),
+                            job.getJob().getService().getPackageName())) {
+                        Slog.w(TAG, "Aborting job " + job.getUid() + ":"
+                                + job.getJob().toString() + " -- package not allowed to start");
+                        mHandler.obtainMessage(MSG_STOP_JOB, job).sendToTarget();
+                        return;
+                    }
+                } catch (RemoteException e) {
+                }
+                if (mConstants.MIN_READY_NON_ACTIVE_JOBS_COUNT > 1
+                        && job.getStandbyBucket() != ACTIVE_INDEX
+                        && (job.getFirstForceBatchedTimeElapsed() == 0
+                        || sElapsedRealtimeClock.millis() - job.getFirstForceBatchedTimeElapsed()
+                                < mConstants.MAX_NON_ACTIVE_JOB_BATCH_DELAY_MS)) {
+                    // Force batching non-ACTIVE jobs. Don't include them in the other counts.
+                    forceBatchedCount++;
+                    if (job.getFirstForceBatchedTimeElapsed() == 0) {
+                        job.setFirstForceBatchedTimeElapsed(sElapsedRealtimeClock.millis());
+                    }
+                } else {
+                    unbatchedCount++;
+                    if (job.getNumFailures() > 0) {
+                        backoffCount++;
+                    }
+                    if (job.hasIdleConstraint()) {
+                        idleCount++;
+                    }
+                    if (job.hasConnectivityConstraint()) {
+                        connectivityCount++;
+                    }
+                    if (job.hasChargingConstraint()) {
+                        chargingCount++;
+                    }
+                    if (job.hasBatteryNotLowConstraint()) {
+                        batteryNotLowCount++;
+                    }
+                    if (job.hasStorageNotLowConstraint()) {
+                        storageNotLowCount++;
+                    }
+                    if (job.hasContentTriggerConstraint()) {
+                        contentCount++;
+                    }
+                }
+                runnableJobs.add(job);
+            } else {
+                evaluateControllerStatesLocked(job);
+            }
+        }
+
+        public void postProcess() {
+            if (backoffCount > 0 ||
+                    idleCount >= mConstants.MIN_IDLE_COUNT ||
+                    connectivityCount >= mConstants.MIN_CONNECTIVITY_COUNT ||
+                    chargingCount >= mConstants.MIN_CHARGING_COUNT ||
+                    batteryNotLowCount >= mConstants.MIN_BATTERY_NOT_LOW_COUNT ||
+                    storageNotLowCount >= mConstants.MIN_STORAGE_NOT_LOW_COUNT ||
+                    contentCount >= mConstants.MIN_CONTENT_COUNT ||
+                    forceBatchedCount >= mConstants.MIN_READY_NON_ACTIVE_JOBS_COUNT ||
+                    (unbatchedCount > 0 && (unbatchedCount + forceBatchedCount)
+                            >= mConstants.MIN_READY_JOBS_COUNT)) {
+                if (DEBUG) {
+                    Slog.d(TAG, "maybeQueueReadyJobsForExecutionLocked: Running jobs.");
+                }
+                noteJobsPending(runnableJobs);
+                mPendingJobs.addAll(runnableJobs);
+                if (mPendingJobs.size() > 1) {
+                    mPendingJobs.sort(mEnqueueTimeComparator);
+                }
+            } else {
+                if (DEBUG) {
+                    Slog.d(TAG, "maybeQueueReadyJobsForExecutionLocked: Not running anything.");
+                }
+            }
+
+            // Be ready for next time
+            reset();
+        }
+
+        @VisibleForTesting
+        void reset() {
+            chargingCount = 0;
+            idleCount =  0;
+            backoffCount = 0;
+            connectivityCount = 0;
+            batteryNotLowCount = 0;
+            storageNotLowCount = 0;
+            contentCount = 0;
+            forceBatchedCount = 0;
+            unbatchedCount = 0;
+            runnableJobs.clear();
+        }
+    }
+    private final MaybeReadyJobQueueFunctor mMaybeQueueFunctor = new MaybeReadyJobQueueFunctor();
+
+    private void maybeQueueReadyJobsForExecutionLocked() {
+        if (DEBUG) Slog.d(TAG, "Maybe queuing ready jobs...");
+
+        noteJobsNonpending(mPendingJobs);
+        mPendingJobs.clear();
+        stopNonReadyActiveJobsLocked();
+        mJobs.forEachJob(mMaybeQueueFunctor);
+        mMaybeQueueFunctor.postProcess();
+    }
+
+    /**
+     * Heartbeat tracking.  The heartbeat alarm is intentionally non-wakeup.
+     */
+    class HeartbeatAlarmListener implements AlarmManager.OnAlarmListener {
+
+        @Override
+        public void onAlarm() {
+            synchronized (mLock) {
+                final long sinceLast = sElapsedRealtimeClock.millis() - mLastHeartbeatTime;
+                final long beatsElapsed = sinceLast / mConstants.STANDBY_HEARTBEAT_TIME;
+                if (beatsElapsed > 0) {
+                    mLastHeartbeatTime += beatsElapsed * mConstants.STANDBY_HEARTBEAT_TIME;
+                    advanceHeartbeatLocked(beatsElapsed);
+                }
+            }
+            setNextHeartbeatAlarm();
+        }
+    }
+
+    // Intentionally does not touch the alarm timing
+    void advanceHeartbeatLocked(long beatsElapsed) {
+        if (!mConstants.USE_HEARTBEATS) {
+            return;
+        }
+        mHeartbeat += beatsElapsed;
+        if (DEBUG_STANDBY) {
+            Slog.v(TAG, "Advancing standby heartbeat by " + beatsElapsed
+                    + " to " + mHeartbeat);
+        }
+        // Don't update ACTIVE or NEVER bucket milestones.  Note that mHeartbeat
+        // will be equal to mNextBucketHeartbeat[bucket] for one beat, during which
+        // new jobs scheduled by apps in that bucket will be permitted to run
+        // immediately.
+        boolean didAdvanceBucket = false;
+        for (int i = 1; i < mNextBucketHeartbeat.length - 1; i++) {
+            // Did we reach or cross a bucket boundary?
+            if (mHeartbeat >= mNextBucketHeartbeat[i]) {
+                didAdvanceBucket = true;
+            }
+            while (mHeartbeat > mNextBucketHeartbeat[i]) {
+                mNextBucketHeartbeat[i] += mConstants.STANDBY_BEATS[i];
+            }
+            if (DEBUG_STANDBY) {
+                Slog.v(TAG, "   Bucket " + i + " next heartbeat "
+                        + mNextBucketHeartbeat[i]);
+            }
+        }
+
+        if (didAdvanceBucket) {
+            if (DEBUG_STANDBY) {
+                Slog.v(TAG, "Hit bucket boundary; reevaluating job runnability");
+            }
+            mHandler.obtainMessage(MSG_CHECK_JOB).sendToTarget();
+        }
+    }
+
+    void setNextHeartbeatAlarm() {
+        final long heartbeatLength;
+        synchronized (mLock) {
+            if (!mConstants.USE_HEARTBEATS) {
+                return;
+            }
+            heartbeatLength = mConstants.STANDBY_HEARTBEAT_TIME;
+        }
+        final long now = sElapsedRealtimeClock.millis();
+        final long nextBeatOrdinal = (now + heartbeatLength) / heartbeatLength;
+        final long nextHeartbeat = nextBeatOrdinal * heartbeatLength;
+        if (DEBUG_STANDBY) {
+            Slog.i(TAG, "Setting heartbeat alarm for " + nextHeartbeat
+                    + " = " + TimeUtils.formatDuration(nextHeartbeat - now));
+        }
+        AlarmManager am = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE);
+        am.setExact(AlarmManager.ELAPSED_REALTIME, nextHeartbeat,
+                HEARTBEAT_TAG, mHeartbeatAlarm, mHandler);
+    }
+
+    /** Returns true if both the calling and source users for the job are started. */
+    private boolean areUsersStartedLocked(final JobStatus job) {
+        boolean sourceStarted = ArrayUtils.contains(mStartedUsers, job.getSourceUserId());
+        if (job.getUserId() == job.getSourceUserId()) {
+            return sourceStarted;
+        }
+        return sourceStarted && ArrayUtils.contains(mStartedUsers, job.getUserId());
+    }
+
+    /**
+     * Criteria for moving a job into the pending queue:
+     *      - It's ready.
+     *      - It's not pending.
+     *      - It's not already running on a JSC.
+     *      - The user that requested the job is running.
+     *      - The job's standby bucket has come due to be runnable.
+     *      - The component is enabled and runnable.
+     */
+    @VisibleForTesting
+    boolean isReadyToBeExecutedLocked(JobStatus job) {
+        final boolean jobReady = job.isReady();
+
+        if (DEBUG) {
+            Slog.v(TAG, "isReadyToBeExecutedLocked: " + job.toShortString()
+                    + " ready=" + jobReady);
+        }
+
+        // This is a condition that is very likely to be false (most jobs that are
+        // scheduled are sitting there, not ready yet) and very cheap to check (just
+        // a few conditions on data in JobStatus).
+        if (!jobReady) {
+            if (job.getSourcePackageName().equals("android.jobscheduler.cts.jobtestapp")) {
+                Slog.v(TAG, "    NOT READY: " + job);
+            }
+            return false;
+        }
+
+        final boolean jobExists = mJobs.containsJob(job);
+
+        final boolean userStarted = areUsersStartedLocked(job);
+
+        if (DEBUG) {
+            Slog.v(TAG, "isReadyToBeExecutedLocked: " + job.toShortString()
+                    + " exists=" + jobExists + " userStarted=" + userStarted);
+        }
+
+        // These are also fairly cheap to check, though they typically will not
+        // be conditions we fail.
+        if (!jobExists || !userStarted) {
+            return false;
+        }
+
+        if (isJobThermalConstrainedLocked(job)) {
+            return false;
+        }
+
+        final boolean jobPending = mPendingJobs.contains(job);
+        final boolean jobActive = isCurrentlyActiveLocked(job);
+
+        if (DEBUG) {
+            Slog.v(TAG, "isReadyToBeExecutedLocked: " + job.toShortString()
+                    + " pending=" + jobPending + " active=" + jobActive);
+        }
+
+        // These can be a little more expensive (especially jobActive, since we need to
+        // go through the array of all potentially active jobs), so we are doing them
+        // later...  but still before checking with the package manager!
+        if (jobPending || jobActive) {
+            return false;
+        }
+
+        if (mConstants.USE_HEARTBEATS) {
+            // If the app is in a non-active standby bucket, make sure we've waited
+            // an appropriate amount of time since the last invocation.  During device-
+            // wide parole, standby bucketing is ignored.
+            //
+            // Jobs in 'active' apps are not subject to standby, nor are jobs that are
+            // specifically marked as exempt.
+            if (DEBUG_STANDBY) {
+                Slog.v(TAG, "isReadyToBeExecutedLocked: " + job.toShortString()
+                        + " parole=" + mInParole + " active=" + job.uidActive
+                        + " exempt=" + job.getJob().isExemptedFromAppStandby());
+            }
+            if (!mInParole
+                    && !job.uidActive
+                    && !job.getJob().isExemptedFromAppStandby()) {
+                final int bucket = job.getStandbyBucket();
+                if (DEBUG_STANDBY) {
+                    Slog.v(TAG, "  bucket=" + bucket + " heartbeat=" + mHeartbeat
+                            + " next=" + mNextBucketHeartbeat[bucket]);
+                }
+                if (mHeartbeat < mNextBucketHeartbeat[bucket]) {
+                    // Only skip this job if the app is still waiting for the end of its nominal
+                    // bucket interval.  Once it's waited that long, we let it go ahead and clear.
+                    // The final (NEVER) bucket is special; we never age those apps' jobs into
+                    // runnability.
+                    final long appLastRan = heartbeatWhenJobsLastRun(job);
+                    if (bucket >= mConstants.STANDBY_BEATS.length
+                            || (mHeartbeat > appLastRan
+                            && mHeartbeat < appLastRan + mConstants.STANDBY_BEATS[bucket])) {
+                        if (job.getWhenStandbyDeferred() == 0) {
+                            if (DEBUG_STANDBY) {
+                                Slog.v(TAG, "Bucket deferral: " + mHeartbeat + " < "
+                                        + (appLastRan + mConstants.STANDBY_BEATS[bucket])
+                                        + " for " + job);
+                            }
+                            job.setWhenStandbyDeferred(sElapsedRealtimeClock.millis());
+                        }
+                        return false;
+                    } else {
+                        if (DEBUG_STANDBY) {
+                            Slog.v(TAG, "Bucket deferred job aged into runnability at "
+                                    + mHeartbeat + " : " + job);
+                        }
+                    }
+                }
+            }
+        }
+
+        // The expensive check: validate that the defined package+service is
+        // still present & viable.
+        return isComponentUsable(job);
+    }
+
+    private boolean isComponentUsable(@NonNull JobStatus job) {
+        final ServiceInfo service;
+        try {
+            // TODO: cache result until we're notified that something in the package changed.
+            service = AppGlobals.getPackageManager().getServiceInfo(
+                    job.getServiceComponent(), PackageManager.MATCH_DEBUG_TRIAGED_MISSING,
+                    job.getUserId());
+        } catch (RemoteException e) {
+            throw e.rethrowAsRuntimeException();
+        }
+
+        if (service == null) {
+            if (DEBUG) {
+                Slog.v(TAG, "isComponentUsable: " + job.toShortString()
+                        + " component not present");
+            }
+            return false;
+        }
+
+        // Everything else checked out so far, so this is the final yes/no check
+        final boolean appIsBad = mActivityManagerInternal.isAppBad(service.applicationInfo);
+        if (DEBUG && appIsBad) {
+            Slog.i(TAG, "App is bad for " + job.toShortString() + " so not runnable");
+        }
+        return !appIsBad;
+    }
+
+    @VisibleForTesting
+    void evaluateControllerStatesLocked(final JobStatus job) {
+        for (int c = mControllers.size() - 1; c >= 0; --c) {
+            final StateController sc = mControllers.get(c);
+            sc.evaluateStateLocked(job);
+        }
+    }
+
+    /**
+     * Returns true if non-job constraint components are in place -- if job.isReady() returns true
+     * and this method returns true, then the job is ready to be executed.
+     */
+    public boolean areComponentsInPlaceLocked(JobStatus job) {
+        // This code is very similar to the code in isReadyToBeExecutedLocked --- it uses the same
+        // conditions.
+
+        final boolean jobExists = mJobs.containsJob(job);
+        final boolean userStarted = areUsersStartedLocked(job);
+
+        if (DEBUG) {
+            Slog.v(TAG, "areComponentsInPlaceLocked: " + job.toShortString()
+                    + " exists=" + jobExists + " userStarted=" + userStarted);
+        }
+
+        // These are also fairly cheap to check, though they typically will not
+        // be conditions we fail.
+        if (!jobExists || !userStarted) {
+            return false;
+        }
+
+        if (isJobThermalConstrainedLocked(job)) {
+            return false;
+        }
+
+        // Job pending/active doesn't affect the readiness of a job.
+
+        // Skipping the heartbeat check as this will only come into play when using the rolling
+        // window quota management system.
+
+        // The expensive check: validate that the defined package+service is
+        // still present & viable.
+        return isComponentUsable(job);
+    }
+
+    /** Returns the maximum amount of time this job could run for. */
+    public long getMaxJobExecutionTimeMs(JobStatus job) {
+        synchronized (mLock) {
+            if (mConstants.USE_HEARTBEATS) {
+                return JobServiceContext.EXECUTING_TIMESLICE_MILLIS;
+            }
+            return Math.min(mQuotaController.getMaxJobExecutionTimeMsLocked(job),
+                    JobServiceContext.EXECUTING_TIMESLICE_MILLIS);
+        }
+    }
+
+    /**
+     * Reconcile jobs in the pending queue against available execution contexts.
+     * A controller can force a job into the pending queue even if it's already running, but
+     * here is where we decide whether to actually execute it.
+     */
+    void maybeRunPendingJobsLocked() {
+        if (DEBUG) {
+            Slog.d(TAG, "pending queue: " + mPendingJobs.size() + " jobs.");
+        }
+        mConcurrencyManager.assignJobsToContextsLocked();
+        reportActiveLocked();
+    }
+
+    private int adjustJobPriority(int curPriority, JobStatus job) {
+        if (curPriority < JobInfo.PRIORITY_TOP_APP) {
+            float factor = mJobPackageTracker.getLoadFactor(job);
+            if (factor >= mConstants.HEAVY_USE_FACTOR) {
+                curPriority += JobInfo.PRIORITY_ADJ_ALWAYS_RUNNING;
+            } else if (factor >= mConstants.MODERATE_USE_FACTOR) {
+                curPriority += JobInfo.PRIORITY_ADJ_OFTEN_RUNNING;
+            }
+        }
+        return curPriority;
+    }
+
+    int evaluateJobPriorityLocked(JobStatus job) {
+        int priority = job.getPriority();
+        if (priority >= JobInfo.PRIORITY_BOUND_FOREGROUND_SERVICE) {
+            return adjustJobPriority(priority, job);
+        }
+        int override = mUidPriorityOverride.get(job.getSourceUid(), 0);
+        if (override != 0) {
+            return adjustJobPriority(override, job);
+        }
+        return adjustJobPriority(priority, job);
+    }
+
+    final class LocalService implements JobSchedulerInternal {
+
+        /**
+         * The current bucket heartbeat ordinal
+         */
+        public long currentHeartbeat() {
+            return getCurrentHeartbeat();
+        }
+
+        /**
+         * Heartbeat ordinal at which the given standby bucket's jobs next become runnable
+         */
+        public long nextHeartbeatForBucket(int bucket) {
+            synchronized (mLock) {
+                return mNextBucketHeartbeat[bucket];
+            }
+        }
+
+        /**
+         * Heartbeat ordinal for the given app.  This is typically the heartbeat at which
+         * the app last ran jobs, so that a newly-scheduled job in an app that hasn't run
+         * jobs in a long time is immediately runnable even if the app is bucketed into
+         * an infrequent time allocation.
+         */
+        public long baseHeartbeatForApp(String packageName, @UserIdInt int userId,
+                final int appStandbyBucket) {
+            if (appStandbyBucket == 0 ||
+                    appStandbyBucket >= mConstants.STANDBY_BEATS.length) {
+                // ACTIVE => everything can be run right away
+                // NEVER => we won't run them anyway, so let them go in the future
+                // as soon as the app enters normal use
+                if (DEBUG_STANDBY) {
+                    Slog.v(TAG, "Base heartbeat forced ZERO for new job in "
+                            + packageName + "/" + userId);
+                }
+                return 0;
+            }
+
+            final long baseHeartbeat = heartbeatWhenJobsLastRun(packageName, userId);
+            if (DEBUG_STANDBY) {
+                Slog.v(TAG, "Base heartbeat " + baseHeartbeat + " for new job in "
+                        + packageName + "/" + userId);
+            }
+            return baseHeartbeat;
+        }
+
+        public void noteJobStart(String packageName, int userId) {
+            synchronized (mLock) {
+                setLastJobHeartbeatLocked(packageName, userId, mHeartbeat);
+            }
+        }
+
+        /**
+         * Returns a list of all pending jobs. A running job is not considered pending. Periodic
+         * jobs are always considered pending.
+         */
+        @Override
+        public List<JobInfo> getSystemScheduledPendingJobs() {
+            synchronized (mLock) {
+                final List<JobInfo> pendingJobs = new ArrayList<JobInfo>();
+                mJobs.forEachJob(Process.SYSTEM_UID, (job) -> {
+                    if (job.getJob().isPeriodic() || !isCurrentlyActiveLocked(job)) {
+                        pendingJobs.add(job.getJob());
+                    }
+                });
+                return pendingJobs;
+            }
+        }
+
+        @Override
+        public void cancelJobsForUid(int uid, String reason) {
+            JobSchedulerService.this.cancelJobsForUid(uid, reason);
+        }
+
+        @Override
+        public void addBackingUpUid(int uid) {
+            synchronized (mLock) {
+                // No need to actually do anything here, since for a full backup the
+                // activity manager will kill the process which will kill the job (and
+                // cause it to restart, but now it can't run).
+                mBackingUpUids.put(uid, uid);
+            }
+        }
+
+        @Override
+        public void removeBackingUpUid(int uid) {
+            synchronized (mLock) {
+                mBackingUpUids.delete(uid);
+                // If there are any jobs for this uid, we need to rebuild the pending list
+                // in case they are now ready to run.
+                if (mJobs.countJobsForUid(uid) > 0) {
+                    mHandler.obtainMessage(MSG_CHECK_JOB).sendToTarget();
+                }
+            }
+        }
+
+        @Override
+        public void clearAllBackingUpUids() {
+            synchronized (mLock) {
+                if (mBackingUpUids.size() > 0) {
+                    mBackingUpUids.clear();
+                    mHandler.obtainMessage(MSG_CHECK_JOB).sendToTarget();
+                }
+            }
+        }
+
+        @Override
+        public void reportAppUsage(String packageName, int userId) {
+            JobSchedulerService.this.reportAppUsage(packageName, userId);
+        }
+
+        @Override
+        public JobStorePersistStats getPersistStats() {
+            synchronized (mLock) {
+                return new JobStorePersistStats(mJobs.getPersistStats());
+            }
+        }
+    }
+
+    /**
+     * Tracking of app assignments to standby buckets
+     */
+    final class StandbyTracker extends AppIdleStateChangeListener {
+
+        // AppIdleStateChangeListener interface for live updates
+
+        @Override
+        public void onAppIdleStateChanged(final String packageName, final @UserIdInt int userId,
+                boolean idle, int bucket, int reason) {
+            // QuotaController handles this now.
+        }
+
+        @Override
+        public void onParoleStateChanged(boolean isParoleOn) {
+            if (DEBUG_STANDBY) {
+                Slog.i(TAG, "Global parole state now " + (isParoleOn ? "ON" : "OFF"));
+            }
+            mInParole = isParoleOn;
+        }
+
+        @Override
+        public void onUserInteractionStarted(String packageName, int userId) {
+            final int uid = mLocalPM.getPackageUid(packageName,
+                    PackageManager.MATCH_UNINSTALLED_PACKAGES, userId);
+            if (uid < 0) {
+                // Quietly ignore; the case is already logged elsewhere
+                return;
+            }
+
+            long sinceLast = mUsageStats.getTimeSinceLastJobRun(packageName, userId);
+            if (sinceLast > 2 * DateUtils.DAY_IN_MILLIS) {
+                // Too long ago, not worth logging
+                sinceLast = 0L;
+            }
+            final DeferredJobCounter counter = new DeferredJobCounter();
+            synchronized (mLock) {
+                mJobs.forEachJobForSourceUid(uid, counter);
+            }
+            if (counter.numDeferred() > 0 || sinceLast > 0) {
+                BatteryStatsInternal mBatteryStatsInternal = LocalServices.getService
+                        (BatteryStatsInternal.class);
+                mBatteryStatsInternal.noteJobsDeferred(uid, counter.numDeferred(), sinceLast);
+                StatsLog.write_non_chained(StatsLog.DEFERRED_JOB_STATS_REPORTED, uid, null,
+                        counter.numDeferred(), sinceLast);
+            }
+        }
+    }
+
+    static class DeferredJobCounter implements Consumer<JobStatus> {
+        private int mDeferred = 0;
+
+        public int numDeferred() {
+            return mDeferred;
+        }
+
+        @Override
+        public void accept(JobStatus job) {
+            if (job.getWhenStandbyDeferred() > 0) {
+                mDeferred++;
+            }
+        }
+    }
+
+    public static int standbyBucketToBucketIndex(int bucket) {
+        // Normalize AppStandby constants to indices into our bookkeeping
+        if (bucket == UsageStatsManager.STANDBY_BUCKET_NEVER) return NEVER_INDEX;
+        else if (bucket > UsageStatsManager.STANDBY_BUCKET_FREQUENT) return RARE_INDEX;
+        else if (bucket > UsageStatsManager.STANDBY_BUCKET_WORKING_SET) return FREQUENT_INDEX;
+        else if (bucket > UsageStatsManager.STANDBY_BUCKET_ACTIVE) return WORKING_INDEX;
+        else return ACTIVE_INDEX;
+    }
+
+    // Static to support external callers
+    public static int standbyBucketForPackage(String packageName, int userId, long elapsedNow) {
+        UsageStatsManagerInternal usageStats = LocalServices.getService(
+                UsageStatsManagerInternal.class);
+        int bucket = usageStats != null
+                ? usageStats.getAppStandbyBucket(packageName, userId, elapsedNow)
+                : 0;
+
+        bucket = standbyBucketToBucketIndex(bucket);
+
+        if (DEBUG_STANDBY) {
+            Slog.v(TAG, packageName + "/" + userId + " standby bucket index: " + bucket);
+        }
+        return bucket;
+    }
+
+    /**
+     * Binder stub trampoline implementation
+     */
+    final class JobSchedulerStub extends IJobScheduler.Stub {
+        /** Cache determination of whether a given app can persist jobs
+         * key is uid of the calling app; value is undetermined/true/false
+         */
+        private final SparseArray<Boolean> mPersistCache = new SparseArray<Boolean>();
+
+        // Enforce that only the app itself (or shared uid participant) can schedule a
+        // job that runs one of the app's services, as well as verifying that the
+        // named service properly requires the BIND_JOB_SERVICE permission
+        private void enforceValidJobRequest(int uid, JobInfo job) {
+            final IPackageManager pm = AppGlobals.getPackageManager();
+            final ComponentName service = job.getService();
+            try {
+                ServiceInfo si = pm.getServiceInfo(service,
+                        PackageManager.MATCH_DIRECT_BOOT_AWARE
+                                | PackageManager.MATCH_DIRECT_BOOT_UNAWARE,
+                        UserHandle.getUserId(uid));
+                if (si == null) {
+                    throw new IllegalArgumentException("No such service " + service);
+                }
+                if (si.applicationInfo.uid != uid) {
+                    throw new IllegalArgumentException("uid " + uid +
+                            " cannot schedule job in " + service.getPackageName());
+                }
+                if (!JobService.PERMISSION_BIND.equals(si.permission)) {
+                    throw new IllegalArgumentException("Scheduled service " + service
+                            + " does not require android.permission.BIND_JOB_SERVICE permission");
+                }
+            } catch (RemoteException e) {
+                // Can't happen; the Package Manager is in this same process
+            }
+        }
+
+        private boolean canPersistJobs(int pid, int uid) {
+            // If we get this far we're good to go; all we need to do now is check
+            // whether the app is allowed to persist its scheduled work.
+            final boolean canPersist;
+            synchronized (mPersistCache) {
+                Boolean cached = mPersistCache.get(uid);
+                if (cached != null) {
+                    canPersist = cached.booleanValue();
+                } else {
+                    // Persisting jobs is tantamount to running at boot, so we permit
+                    // it when the app has declared that it uses the RECEIVE_BOOT_COMPLETED
+                    // permission
+                    int result = getContext().checkPermission(
+                            android.Manifest.permission.RECEIVE_BOOT_COMPLETED, pid, uid);
+                    canPersist = (result == PackageManager.PERMISSION_GRANTED);
+                    mPersistCache.put(uid, canPersist);
+                }
+            }
+            return canPersist;
+        }
+
+        private void validateJobFlags(JobInfo job, int callingUid) {
+            if ((job.getFlags() & JobInfo.FLAG_WILL_BE_FOREGROUND) != 0) {
+                getContext().enforceCallingOrSelfPermission(
+                        android.Manifest.permission.CONNECTIVITY_INTERNAL, TAG);
+            }
+            if ((job.getFlags() & JobInfo.FLAG_EXEMPT_FROM_APP_STANDBY) != 0) {
+                if (callingUid != Process.SYSTEM_UID) {
+                    throw new SecurityException("Job has invalid flags");
+                }
+                if (job.isPeriodic()) {
+                    Slog.wtf(TAG, "Periodic jobs mustn't have"
+                            + " FLAG_EXEMPT_FROM_APP_STANDBY. Job=" + job);
+                }
+            }
+        }
+
+        // IJobScheduler implementation
+        @Override
+        public int schedule(JobInfo job) throws RemoteException {
+            if (DEBUG) {
+                Slog.d(TAG, "Scheduling job: " + job.toString());
+            }
+            final int pid = Binder.getCallingPid();
+            final int uid = Binder.getCallingUid();
+            final int userId = UserHandle.getUserId(uid);
+
+            enforceValidJobRequest(uid, job);
+            if (job.isPersisted()) {
+                if (!canPersistJobs(pid, uid)) {
+                    throw new IllegalArgumentException("Error: requested job be persisted without"
+                            + " holding RECEIVE_BOOT_COMPLETED permission.");
+                }
+            }
+
+            validateJobFlags(job, uid);
+
+            long ident = Binder.clearCallingIdentity();
+            try {
+                return JobSchedulerService.this.scheduleAsPackage(job, null, uid, null, userId,
+                        null);
+            } finally {
+                Binder.restoreCallingIdentity(ident);
+            }
+        }
+
+        // IJobScheduler implementation
+        @Override
+        public int enqueue(JobInfo job, JobWorkItem work) throws RemoteException {
+            if (DEBUG) {
+                Slog.d(TAG, "Enqueueing job: " + job.toString() + " work: " + work);
+            }
+            final int uid = Binder.getCallingUid();
+            final int userId = UserHandle.getUserId(uid);
+
+            enforceValidJobRequest(uid, job);
+            if (job.isPersisted()) {
+                throw new IllegalArgumentException("Can't enqueue work for persisted jobs");
+            }
+            if (work == null) {
+                throw new NullPointerException("work is null");
+            }
+
+            validateJobFlags(job, uid);
+
+            long ident = Binder.clearCallingIdentity();
+            try {
+                return JobSchedulerService.this.scheduleAsPackage(job, work, uid, null, userId,
+                        null);
+            } finally {
+                Binder.restoreCallingIdentity(ident);
+            }
+        }
+
+        @Override
+        public int scheduleAsPackage(JobInfo job, String packageName, int userId, String tag)
+                throws RemoteException {
+            final int callerUid = Binder.getCallingUid();
+            if (DEBUG) {
+                Slog.d(TAG, "Caller uid " + callerUid + " scheduling job: " + job.toString()
+                        + " on behalf of " + packageName + "/");
+            }
+
+            if (packageName == null) {
+                throw new NullPointerException("Must specify a package for scheduleAsPackage()");
+            }
+
+            int mayScheduleForOthers = getContext().checkCallingOrSelfPermission(
+                    android.Manifest.permission.UPDATE_DEVICE_STATS);
+            if (mayScheduleForOthers != PackageManager.PERMISSION_GRANTED) {
+                throw new SecurityException("Caller uid " + callerUid
+                        + " not permitted to schedule jobs for other apps");
+            }
+
+            validateJobFlags(job, callerUid);
+
+            long ident = Binder.clearCallingIdentity();
+            try {
+                return JobSchedulerService.this.scheduleAsPackage(job, null, callerUid,
+                        packageName, userId, tag);
+            } finally {
+                Binder.restoreCallingIdentity(ident);
+            }
+        }
+
+        @Override
+        public ParceledListSlice<JobInfo> getAllPendingJobs() throws RemoteException {
+            final int uid = Binder.getCallingUid();
+
+            long ident = Binder.clearCallingIdentity();
+            try {
+                return new ParceledListSlice<>(JobSchedulerService.this.getPendingJobs(uid));
+            } finally {
+                Binder.restoreCallingIdentity(ident);
+            }
+        }
+
+        @Override
+        public JobInfo getPendingJob(int jobId) throws RemoteException {
+            final int uid = Binder.getCallingUid();
+
+            long ident = Binder.clearCallingIdentity();
+            try {
+                return JobSchedulerService.this.getPendingJob(uid, jobId);
+            } finally {
+                Binder.restoreCallingIdentity(ident);
+            }
+        }
+
+        @Override
+        public void cancelAll() throws RemoteException {
+            final int uid = Binder.getCallingUid();
+            long ident = Binder.clearCallingIdentity();
+            try {
+                JobSchedulerService.this.cancelJobsForUid(uid,
+                        "cancelAll() called by app, callingUid=" + uid);
+            } finally {
+                Binder.restoreCallingIdentity(ident);
+            }
+        }
+
+        @Override
+        public void cancel(int jobId) throws RemoteException {
+            final int uid = Binder.getCallingUid();
+
+            long ident = Binder.clearCallingIdentity();
+            try {
+                JobSchedulerService.this.cancelJob(uid, jobId, uid);
+            } finally {
+                Binder.restoreCallingIdentity(ident);
+            }
+        }
+
+        /**
+         * "dumpsys" infrastructure
+         */
+        @Override
+        public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
+            if (!DumpUtils.checkDumpAndUsageStatsPermission(getContext(), TAG, pw)) return;
+
+            int filterUid = -1;
+            boolean proto = false;
+            if (!ArrayUtils.isEmpty(args)) {
+                int opti = 0;
+                while (opti < args.length) {
+                    String arg = args[opti];
+                    if ("-h".equals(arg)) {
+                        dumpHelp(pw);
+                        return;
+                    } else if ("-a".equals(arg)) {
+                        // Ignore, we always dump all.
+                    } else if ("--proto".equals(arg)) {
+                        proto = true;
+                    } else if (arg.length() > 0 && arg.charAt(0) == '-') {
+                        pw.println("Unknown option: " + arg);
+                        return;
+                    } else {
+                        break;
+                    }
+                    opti++;
+                }
+                if (opti < args.length) {
+                    String pkg = args[opti];
+                    try {
+                        filterUid = getContext().getPackageManager().getPackageUid(pkg,
+                                PackageManager.MATCH_ANY_USER);
+                    } catch (NameNotFoundException ignored) {
+                        pw.println("Invalid package: " + pkg);
+                        return;
+                    }
+                }
+            }
+
+            final long identityToken = Binder.clearCallingIdentity();
+            try {
+                if (proto) {
+                    JobSchedulerService.this.dumpInternalProto(fd, filterUid);
+                } else {
+                    JobSchedulerService.this.dumpInternal(new IndentingPrintWriter(pw, "  "),
+                            filterUid);
+                }
+            } finally {
+                Binder.restoreCallingIdentity(identityToken);
+            }
+        }
+
+        @Override
+        public void onShellCommand(FileDescriptor in, FileDescriptor out, FileDescriptor err,
+                String[] args, ShellCallback callback, ResultReceiver resultReceiver) {
+                (new JobSchedulerShellCommand(JobSchedulerService.this)).exec(
+                        this, in, out, err, args, callback, resultReceiver);
+        }
+
+        /**
+         * <b>For internal system user only!</b>
+         * Returns a list of all currently-executing jobs.
+         */
+        @Override
+        public List<JobInfo> getStartedJobs() {
+            final int uid = Binder.getCallingUid();
+            if (uid != Process.SYSTEM_UID) {
+                throw new SecurityException(
+                    "getStartedJobs() is system internal use only.");
+            }
+
+            final ArrayList<JobInfo> runningJobs;
+
+            synchronized (mLock) {
+                runningJobs = new ArrayList<>(mActiveServices.size());
+                for (JobServiceContext jsc : mActiveServices) {
+                    final JobStatus job = jsc.getRunningJobLocked();
+                    if (job != null) {
+                        runningJobs.add(job.getJob());
+                    }
+                }
+            }
+
+            return runningJobs;
+        }
+
+        /**
+         * <b>For internal system user only!</b>
+         * Returns a snapshot of the state of all jobs known to the system.
+         *
+         * <p class="note">This is a slow operation, so it should be called sparingly.
+         */
+        @Override
+        public ParceledListSlice<JobSnapshot> getAllJobSnapshots() {
+            final int uid = Binder.getCallingUid();
+            if (uid != Process.SYSTEM_UID) {
+                throw new SecurityException(
+                    "getAllJobSnapshots() is system internal use only.");
+            }
+            synchronized (mLock) {
+                final ArrayList<JobSnapshot> snapshots = new ArrayList<>(mJobs.size());
+                mJobs.forEachJob((job) -> snapshots.add(
+                        new JobSnapshot(job.getJob(), job.getSatisfiedConstraintFlags(),
+                                isReadyToBeExecutedLocked(job))));
+                return new ParceledListSlice<>(snapshots);
+            }
+        }
+    };
+
+    // Shell command infrastructure: run the given job immediately
+    int executeRunCommand(String pkgName, int userId, int jobId, boolean force) {
+        if (DEBUG) {
+            Slog.v(TAG, "executeRunCommand(): " + pkgName + "/" + userId
+                    + " " + jobId + " f=" + force);
+        }
+
+        try {
+            final int uid = AppGlobals.getPackageManager().getPackageUid(pkgName, 0,
+                    userId != UserHandle.USER_ALL ? userId : UserHandle.USER_SYSTEM);
+            if (uid < 0) {
+                return JobSchedulerShellCommand.CMD_ERR_NO_PACKAGE;
+            }
+
+            synchronized (mLock) {
+                final JobStatus js = mJobs.getJobByUidAndJobId(uid, jobId);
+                if (js == null) {
+                    return JobSchedulerShellCommand.CMD_ERR_NO_JOB;
+                }
+
+                js.overrideState = (force) ? JobStatus.OVERRIDE_FULL : JobStatus.OVERRIDE_SOFT;
+                if (!js.isConstraintsSatisfied()) {
+                    js.overrideState = 0;
+                    return JobSchedulerShellCommand.CMD_ERR_CONSTRAINTS;
+                }
+
+                queueReadyJobsForExecutionLocked();
+                maybeRunPendingJobsLocked();
+            }
+        } catch (RemoteException e) {
+            // can't happen
+        }
+        return 0;
+    }
+
+    // Shell command infrastructure: immediately timeout currently executing jobs
+    int executeTimeoutCommand(PrintWriter pw, String pkgName, int userId,
+            boolean hasJobId, int jobId) {
+        if (DEBUG) {
+            Slog.v(TAG, "executeTimeoutCommand(): " + pkgName + "/" + userId + " " + jobId);
+        }
+
+        synchronized (mLock) {
+            boolean foundSome = false;
+            for (int i=0; i<mActiveServices.size(); i++) {
+                final JobServiceContext jc = mActiveServices.get(i);
+                final JobStatus js = jc.getRunningJobLocked();
+                if (jc.timeoutIfExecutingLocked(pkgName, userId, hasJobId, jobId, "shell")) {
+                    foundSome = true;
+                    pw.print("Timing out: ");
+                    js.printUniqueId(pw);
+                    pw.print(" ");
+                    pw.println(js.getServiceComponent().flattenToShortString());
+                }
+            }
+            if (!foundSome) {
+                pw.println("No matching executing jobs found.");
+            }
+        }
+        return 0;
+    }
+
+    // Shell command infrastructure: cancel a scheduled job
+    int executeCancelCommand(PrintWriter pw, String pkgName, int userId,
+            boolean hasJobId, int jobId) {
+        if (DEBUG) {
+            Slog.v(TAG, "executeCancelCommand(): " + pkgName + "/" + userId + " " + jobId);
+        }
+
+        int pkgUid = -1;
+        try {
+            IPackageManager pm = AppGlobals.getPackageManager();
+            pkgUid = pm.getPackageUid(pkgName, 0, userId);
+        } catch (RemoteException e) { /* can't happen */ }
+
+        if (pkgUid < 0) {
+            pw.println("Package " + pkgName + " not found.");
+            return JobSchedulerShellCommand.CMD_ERR_NO_PACKAGE;
+        }
+
+        if (!hasJobId) {
+            pw.println("Canceling all jobs for " + pkgName + " in user " + userId);
+            if (!cancelJobsForUid(pkgUid, "cancel shell command for package")) {
+                pw.println("No matching jobs found.");
+            }
+        } else {
+            pw.println("Canceling job " + pkgName + "/#" + jobId + " in user " + userId);
+            if (!cancelJob(pkgUid, jobId, Process.SHELL_UID)) {
+                pw.println("No matching job found.");
+            }
+        }
+
+        return 0;
+    }
+
+    void setMonitorBattery(boolean enabled) {
+        synchronized (mLock) {
+            if (mBatteryController != null) {
+                mBatteryController.getTracker().setMonitorBatteryLocked(enabled);
+            }
+        }
+    }
+
+    int getBatterySeq() {
+        synchronized (mLock) {
+            return mBatteryController != null ? mBatteryController.getTracker().getSeq() : -1;
+        }
+    }
+
+    boolean getBatteryCharging() {
+        synchronized (mLock) {
+            return mBatteryController != null
+                    ? mBatteryController.getTracker().isOnStablePower() : false;
+        }
+    }
+
+    boolean getBatteryNotLow() {
+        synchronized (mLock) {
+            return mBatteryController != null
+                    ? mBatteryController.getTracker().isBatteryNotLow() : false;
+        }
+    }
+
+    int getStorageSeq() {
+        synchronized (mLock) {
+            return mStorageController != null ? mStorageController.getTracker().getSeq() : -1;
+        }
+    }
+
+    boolean getStorageNotLow() {
+        synchronized (mLock) {
+            return mStorageController != null
+                    ? mStorageController.getTracker().isStorageNotLow() : false;
+        }
+    }
+
+    long getCurrentHeartbeat() {
+        synchronized (mLock) {
+            return mHeartbeat;
+        }
+    }
+
+    // Shell command infrastructure
+    int getJobState(PrintWriter pw, String pkgName, int userId, int jobId) {
+        try {
+            final int uid = AppGlobals.getPackageManager().getPackageUid(pkgName, 0,
+                    userId != UserHandle.USER_ALL ? userId : UserHandle.USER_SYSTEM);
+            if (uid < 0) {
+                pw.print("unknown("); pw.print(pkgName); pw.println(")");
+                return JobSchedulerShellCommand.CMD_ERR_NO_PACKAGE;
+            }
+
+            synchronized (mLock) {
+                final JobStatus js = mJobs.getJobByUidAndJobId(uid, jobId);
+                if (DEBUG) Slog.d(TAG, "get-job-state " + uid + "/" + jobId + ": " + js);
+                if (js == null) {
+                    pw.print("unknown("); UserHandle.formatUid(pw, uid);
+                    pw.print("/jid"); pw.print(jobId); pw.println(")");
+                    return JobSchedulerShellCommand.CMD_ERR_NO_JOB;
+                }
+
+                boolean printed = false;
+                if (mPendingJobs.contains(js)) {
+                    pw.print("pending");
+                    printed = true;
+                }
+                if (isCurrentlyActiveLocked(js)) {
+                    if (printed) {
+                        pw.print(" ");
+                    }
+                    printed = true;
+                    pw.println("active");
+                }
+                if (!ArrayUtils.contains(mStartedUsers, js.getUserId())) {
+                    if (printed) {
+                        pw.print(" ");
+                    }
+                    printed = true;
+                    pw.println("user-stopped");
+                }
+                if (!ArrayUtils.contains(mStartedUsers, js.getSourceUserId())) {
+                    if (printed) {
+                        pw.print(" ");
+                    }
+                    printed = true;
+                    pw.println("source-user-stopped");
+                }
+                if (mBackingUpUids.indexOfKey(js.getSourceUid()) >= 0) {
+                    if (printed) {
+                        pw.print(" ");
+                    }
+                    printed = true;
+                    pw.println("backing-up");
+                }
+                boolean componentPresent = false;
+                try {
+                    componentPresent = (AppGlobals.getPackageManager().getServiceInfo(
+                            js.getServiceComponent(),
+                            PackageManager.MATCH_DEBUG_TRIAGED_MISSING,
+                            js.getUserId()) != null);
+                } catch (RemoteException e) {
+                }
+                if (!componentPresent) {
+                    if (printed) {
+                        pw.print(" ");
+                    }
+                    printed = true;
+                    pw.println("no-component");
+                }
+                if (js.isReady()) {
+                    if (printed) {
+                        pw.print(" ");
+                    }
+                    printed = true;
+                    pw.println("ready");
+                }
+                if (!printed) {
+                    pw.print("waiting");
+                }
+                pw.println();
+            }
+        } catch (RemoteException e) {
+            // can't happen
+        }
+        return 0;
+    }
+
+    // Shell command infrastructure
+    int executeHeartbeatCommand(PrintWriter pw, int numBeats) {
+        if (numBeats < 1) {
+            pw.println(getCurrentHeartbeat());
+            return 0;
+        }
+
+        pw.print("Advancing standby heartbeat by ");
+        pw.println(numBeats);
+        synchronized (mLock) {
+            advanceHeartbeatLocked(numBeats);
+        }
+        return 0;
+    }
+
+    void triggerDockState(boolean idleState) {
+        final Intent dockIntent;
+        if (idleState) {
+            dockIntent = new Intent(Intent.ACTION_DOCK_IDLE);
+        } else {
+            dockIntent = new Intent(Intent.ACTION_DOCK_ACTIVE);
+        }
+        dockIntent.setPackage("android");
+        dockIntent.addFlags(Intent.FLAG_RECEIVER_REGISTERED_ONLY | Intent.FLAG_RECEIVER_FOREGROUND);
+        getContext().sendBroadcastAsUser(dockIntent, UserHandle.ALL);
+    }
+
+    static void dumpHelp(PrintWriter pw) {
+        pw.println("Job Scheduler (jobscheduler) dump options:");
+        pw.println("  [-h] [package] ...");
+        pw.println("    -h: print this help");
+        pw.println("  [package] is an optional package name to limit the output to.");
+    }
+
+    /** Sort jobs by caller UID, then by Job ID. */
+    private static void sortJobs(List<JobStatus> jobs) {
+        Collections.sort(jobs, new Comparator<JobStatus>() {
+            @Override
+            public int compare(JobStatus o1, JobStatus o2) {
+                int uid1 = o1.getUid();
+                int uid2 = o2.getUid();
+                int id1 = o1.getJobId();
+                int id2 = o2.getJobId();
+                if (uid1 != uid2) {
+                    return uid1 < uid2 ? -1 : 1;
+                }
+                return id1 < id2 ? -1 : (id1 > id2 ? 1 : 0);
+            }
+        });
+    }
+
+    void dumpInternal(final IndentingPrintWriter pw, int filterUid) {
+        final int filterUidFinal = UserHandle.getAppId(filterUid);
+        final long now = sSystemClock.millis();
+        final long nowElapsed = sElapsedRealtimeClock.millis();
+        final long nowUptime = sUptimeMillisClock.millis();
+
+        final Predicate<JobStatus> predicate = (js) -> {
+            return filterUidFinal == -1 || UserHandle.getAppId(js.getUid()) == filterUidFinal
+                    || UserHandle.getAppId(js.getSourceUid()) == filterUidFinal;
+        };
+        synchronized (mLock) {
+            mConstants.dump(pw);
+            for (StateController controller : mControllers) {
+                pw.increaseIndent();
+                controller.dumpConstants(pw);
+                pw.decreaseIndent();
+            }
+            pw.println();
+
+            pw.println("  Heartbeat:");
+            pw.print("    Current:    "); pw.println(mHeartbeat);
+            pw.println("    Next");
+            pw.print("      ACTIVE:   "); pw.println(mNextBucketHeartbeat[0]);
+            pw.print("      WORKING:  "); pw.println(mNextBucketHeartbeat[1]);
+            pw.print("      FREQUENT: "); pw.println(mNextBucketHeartbeat[2]);
+            pw.print("      RARE:     "); pw.println(mNextBucketHeartbeat[3]);
+            pw.print("    Last heartbeat: ");
+            TimeUtils.formatDuration(mLastHeartbeatTime, nowElapsed, pw);
+            pw.println();
+            pw.print("    Next heartbeat: ");
+            TimeUtils.formatDuration(mLastHeartbeatTime + mConstants.STANDBY_HEARTBEAT_TIME,
+                    nowElapsed, pw);
+            pw.println();
+            pw.print("    In parole?: ");
+            pw.print(mInParole);
+            pw.println();
+            pw.print("    In thermal throttling?: ");
+            pw.print(mThermalConstraint);
+            pw.println();
+            pw.println();
+
+            pw.println("Started users: " + Arrays.toString(mStartedUsers));
+            pw.print("Registered ");
+            pw.print(mJobs.size());
+            pw.println(" jobs:");
+            if (mJobs.size() > 0) {
+                final List<JobStatus> jobs = mJobs.mJobSet.getAllJobs();
+                sortJobs(jobs);
+                for (JobStatus job : jobs) {
+                    pw.print("  JOB #"); job.printUniqueId(pw); pw.print(": ");
+                    pw.println(job.toShortStringExceptUniqueId());
+
+                    // Skip printing details if the caller requested a filter
+                    if (!predicate.test(job)) {
+                        continue;
+                    }
+
+                    job.dump(pw, "    ", true, nowElapsed);
+                    pw.print("    Last run heartbeat: ");
+                    pw.print(heartbeatWhenJobsLastRun(job));
+                    pw.println();
+
+                    pw.print("    Ready: ");
+                    pw.print(isReadyToBeExecutedLocked(job));
+                    pw.print(" (job=");
+                    pw.print(job.isReady());
+                    pw.print(" user=");
+                    pw.print(areUsersStartedLocked(job));
+                    pw.print(" !pending=");
+                    pw.print(!mPendingJobs.contains(job));
+                    pw.print(" !active=");
+                    pw.print(!isCurrentlyActiveLocked(job));
+                    pw.print(" !backingup=");
+                    pw.print(!(mBackingUpUids.indexOfKey(job.getSourceUid()) >= 0));
+                    pw.print(" comp=");
+                    boolean componentPresent = false;
+                    try {
+                        componentPresent = (AppGlobals.getPackageManager().getServiceInfo(
+                                job.getServiceComponent(),
+                                PackageManager.MATCH_DEBUG_TRIAGED_MISSING,
+                                job.getUserId()) != null);
+                    } catch (RemoteException e) {
+                    }
+                    pw.print(componentPresent);
+                    pw.println(")");
+                }
+            } else {
+                pw.println("  None.");
+            }
+            for (int i=0; i<mControllers.size(); i++) {
+                pw.println();
+                pw.println(mControllers.get(i).getClass().getSimpleName() + ":");
+                pw.increaseIndent();
+                mControllers.get(i).dumpControllerStateLocked(pw, predicate);
+                pw.decreaseIndent();
+            }
+            pw.println();
+            pw.println("Uid priority overrides:");
+            for (int i=0; i< mUidPriorityOverride.size(); i++) {
+                int uid = mUidPriorityOverride.keyAt(i);
+                if (filterUidFinal == -1 || filterUidFinal == UserHandle.getAppId(uid)) {
+                    pw.print("  "); pw.print(UserHandle.formatUid(uid));
+                    pw.print(": "); pw.println(mUidPriorityOverride.valueAt(i));
+                }
+            }
+            if (mBackingUpUids.size() > 0) {
+                pw.println();
+                pw.println("Backing up uids:");
+                boolean first = true;
+                for (int i = 0; i < mBackingUpUids.size(); i++) {
+                    int uid = mBackingUpUids.keyAt(i);
+                    if (filterUidFinal == -1 || filterUidFinal == UserHandle.getAppId(uid)) {
+                        if (first) {
+                            pw.print("  ");
+                            first = false;
+                        } else {
+                            pw.print(", ");
+                        }
+                        pw.print(UserHandle.formatUid(uid));
+                    }
+                }
+                pw.println();
+            }
+            pw.println();
+            mJobPackageTracker.dump(pw, "", filterUidFinal);
+            pw.println();
+            if (mJobPackageTracker.dumpHistory(pw, "", filterUidFinal)) {
+                pw.println();
+            }
+            pw.println("Pending queue:");
+            for (int i=0; i<mPendingJobs.size(); i++) {
+                JobStatus job = mPendingJobs.get(i);
+                pw.print("  Pending #"); pw.print(i); pw.print(": ");
+                pw.println(job.toShortString());
+                job.dump(pw, "    ", false, nowElapsed);
+                int priority = evaluateJobPriorityLocked(job);
+                pw.print("    Evaluated priority: ");
+                pw.println(JobInfo.getPriorityString(priority));
+
+                pw.print("    Tag: "); pw.println(job.getTag());
+                pw.print("    Enq: ");
+                TimeUtils.formatDuration(job.madePending - nowUptime, pw);
+                pw.println();
+            }
+            pw.println();
+            pw.println("Active jobs:");
+            for (int i=0; i<mActiveServices.size(); i++) {
+                JobServiceContext jsc = mActiveServices.get(i);
+                pw.print("  Slot #"); pw.print(i); pw.print(": ");
+                final JobStatus job = jsc.getRunningJobLocked();
+                if (job == null) {
+                    if (jsc.mStoppedReason != null) {
+                        pw.print("inactive since ");
+                        TimeUtils.formatDuration(jsc.mStoppedTime, nowElapsed, pw);
+                        pw.print(", stopped because: ");
+                        pw.println(jsc.mStoppedReason);
+                    } else {
+                        pw.println("inactive");
+                    }
+                    continue;
+                } else {
+                    pw.println(job.toShortString());
+                    pw.print("    Running for: ");
+                    TimeUtils.formatDuration(nowElapsed - jsc.getExecutionStartTimeElapsed(), pw);
+                    pw.print(", timeout at: ");
+                    TimeUtils.formatDuration(jsc.getTimeoutElapsed() - nowElapsed, pw);
+                    pw.println();
+                    job.dump(pw, "    ", false, nowElapsed);
+                    int priority = evaluateJobPriorityLocked(jsc.getRunningJobLocked());
+                    pw.print("    Evaluated priority: ");
+                    pw.println(JobInfo.getPriorityString(priority));
+
+                    pw.print("    Active at ");
+                    TimeUtils.formatDuration(job.madeActive - nowUptime, pw);
+                    pw.print(", pending for ");
+                    TimeUtils.formatDuration(job.madeActive - job.madePending, pw);
+                    pw.println();
+                }
+            }
+            if (filterUid == -1) {
+                pw.println();
+                pw.print("mReadyToRock="); pw.println(mReadyToRock);
+                pw.print("mReportedActive="); pw.println(mReportedActive);
+            }
+            pw.println();
+
+            mConcurrencyManager.dumpLocked(pw, now, nowElapsed);
+
+            pw.println();
+            pw.print("PersistStats: ");
+            pw.println(mJobs.getPersistStats());
+        }
+        pw.println();
+    }
+
+    void dumpInternalProto(final FileDescriptor fd, int filterUid) {
+        ProtoOutputStream proto = new ProtoOutputStream(fd);
+        final int filterUidFinal = UserHandle.getAppId(filterUid);
+        final long now = sSystemClock.millis();
+        final long nowElapsed = sElapsedRealtimeClock.millis();
+        final long nowUptime = sUptimeMillisClock.millis();
+        final Predicate<JobStatus> predicate = (js) -> {
+            return filterUidFinal == -1 || UserHandle.getAppId(js.getUid()) == filterUidFinal
+                    || UserHandle.getAppId(js.getSourceUid()) == filterUidFinal;
+        };
+
+        synchronized (mLock) {
+            final long settingsToken = proto.start(JobSchedulerServiceDumpProto.SETTINGS);
+            mConstants.dump(proto);
+            for (StateController controller : mControllers) {
+                controller.dumpConstants(proto);
+            }
+            proto.end(settingsToken);
+
+            proto.write(JobSchedulerServiceDumpProto.CURRENT_HEARTBEAT, mHeartbeat);
+            proto.write(JobSchedulerServiceDumpProto.NEXT_HEARTBEAT, mNextBucketHeartbeat[0]);
+            proto.write(JobSchedulerServiceDumpProto.NEXT_HEARTBEAT, mNextBucketHeartbeat[1]);
+            proto.write(JobSchedulerServiceDumpProto.NEXT_HEARTBEAT, mNextBucketHeartbeat[2]);
+            proto.write(JobSchedulerServiceDumpProto.NEXT_HEARTBEAT, mNextBucketHeartbeat[3]);
+            proto.write(JobSchedulerServiceDumpProto.LAST_HEARTBEAT_TIME_MILLIS,
+                    mLastHeartbeatTime - nowUptime);
+            proto.write(JobSchedulerServiceDumpProto.NEXT_HEARTBEAT_TIME_MILLIS,
+                    mLastHeartbeatTime + mConstants.STANDBY_HEARTBEAT_TIME - nowUptime);
+            proto.write(JobSchedulerServiceDumpProto.IN_PAROLE, mInParole);
+            proto.write(JobSchedulerServiceDumpProto.IN_THERMAL, mThermalConstraint);
+
+            for (int u : mStartedUsers) {
+                proto.write(JobSchedulerServiceDumpProto.STARTED_USERS, u);
+            }
+            if (mJobs.size() > 0) {
+                final List<JobStatus> jobs = mJobs.mJobSet.getAllJobs();
+                sortJobs(jobs);
+                for (JobStatus job : jobs) {
+                    final long rjToken = proto.start(JobSchedulerServiceDumpProto.REGISTERED_JOBS);
+                    job.writeToShortProto(proto, JobSchedulerServiceDumpProto.RegisteredJob.INFO);
+
+                    // Skip printing details if the caller requested a filter
+                    if (!predicate.test(job)) {
+                        continue;
+                    }
+
+                    job.dump(proto, JobSchedulerServiceDumpProto.RegisteredJob.DUMP, true, nowElapsed);
+
+                    // isReadyToBeExecuted
+                    proto.write(JobSchedulerServiceDumpProto.RegisteredJob.IS_JOB_READY,
+                            job.isReady());
+                    proto.write(JobSchedulerServiceDumpProto.RegisteredJob.IS_USER_STARTED,
+                            areUsersStartedLocked(job));
+                    proto.write(JobSchedulerServiceDumpProto.RegisteredJob.IS_JOB_PENDING,
+                            mPendingJobs.contains(job));
+                    proto.write(JobSchedulerServiceDumpProto.RegisteredJob.IS_JOB_CURRENTLY_ACTIVE,
+                            isCurrentlyActiveLocked(job));
+                    proto.write(JobSchedulerServiceDumpProto.RegisteredJob.IS_UID_BACKING_UP,
+                            mBackingUpUids.indexOfKey(job.getSourceUid()) >= 0);
+                    boolean componentPresent = false;
+                    try {
+                        componentPresent = (AppGlobals.getPackageManager().getServiceInfo(
+                                job.getServiceComponent(),
+                                PackageManager.MATCH_DEBUG_TRIAGED_MISSING,
+                                job.getUserId()) != null);
+                    } catch (RemoteException e) {
+                    }
+                    proto.write(JobSchedulerServiceDumpProto.RegisteredJob.IS_COMPONENT_PRESENT,
+                            componentPresent);
+                    proto.write(RegisteredJob.LAST_RUN_HEARTBEAT, heartbeatWhenJobsLastRun(job));
+
+                    proto.end(rjToken);
+                }
+            }
+            for (StateController controller : mControllers) {
+                controller.dumpControllerStateLocked(
+                        proto, JobSchedulerServiceDumpProto.CONTROLLERS, predicate);
+            }
+            for (int i=0; i< mUidPriorityOverride.size(); i++) {
+                int uid = mUidPriorityOverride.keyAt(i);
+                if (filterUidFinal == -1 || filterUidFinal == UserHandle.getAppId(uid)) {
+                    long pToken = proto.start(JobSchedulerServiceDumpProto.PRIORITY_OVERRIDES);
+                    proto.write(JobSchedulerServiceDumpProto.PriorityOverride.UID, uid);
+                    proto.write(JobSchedulerServiceDumpProto.PriorityOverride.OVERRIDE_VALUE,
+                            mUidPriorityOverride.valueAt(i));
+                    proto.end(pToken);
+                }
+            }
+            for (int i = 0; i < mBackingUpUids.size(); i++) {
+                int uid = mBackingUpUids.keyAt(i);
+                if (filterUidFinal == -1 || filterUidFinal == UserHandle.getAppId(uid)) {
+                    proto.write(JobSchedulerServiceDumpProto.BACKING_UP_UIDS, uid);
+                }
+            }
+
+            mJobPackageTracker.dump(proto, JobSchedulerServiceDumpProto.PACKAGE_TRACKER,
+                    filterUidFinal);
+            mJobPackageTracker.dumpHistory(proto, JobSchedulerServiceDumpProto.HISTORY,
+                    filterUidFinal);
+
+            for (JobStatus job : mPendingJobs) {
+                final long pjToken = proto.start(JobSchedulerServiceDumpProto.PENDING_JOBS);
+
+                job.writeToShortProto(proto, PendingJob.INFO);
+                job.dump(proto, PendingJob.DUMP, false, nowElapsed);
+                proto.write(PendingJob.EVALUATED_PRIORITY, evaluateJobPriorityLocked(job));
+                proto.write(PendingJob.ENQUEUED_DURATION_MS, nowUptime - job.madePending);
+
+                proto.end(pjToken);
+            }
+            for (JobServiceContext jsc : mActiveServices) {
+                final long ajToken = proto.start(JobSchedulerServiceDumpProto.ACTIVE_JOBS);
+                final JobStatus job = jsc.getRunningJobLocked();
+
+                if (job == null) {
+                    final long ijToken = proto.start(ActiveJob.INACTIVE);
+
+                        proto.write(ActiveJob.InactiveJob.TIME_SINCE_STOPPED_MS,
+                                nowElapsed - jsc.mStoppedTime);
+                    if (jsc.mStoppedReason != null) {
+                        proto.write(ActiveJob.InactiveJob.STOPPED_REASON,
+                                jsc.mStoppedReason);
+                    }
+
+                    proto.end(ijToken);
+                } else {
+                    final long rjToken = proto.start(ActiveJob.RUNNING);
+
+                    job.writeToShortProto(proto, ActiveJob.RunningJob.INFO);
+
+                    proto.write(ActiveJob.RunningJob.RUNNING_DURATION_MS,
+                            nowElapsed - jsc.getExecutionStartTimeElapsed());
+                    proto.write(ActiveJob.RunningJob.TIME_UNTIL_TIMEOUT_MS,
+                            jsc.getTimeoutElapsed() - nowElapsed);
+
+                    job.dump(proto, ActiveJob.RunningJob.DUMP, false, nowElapsed);
+
+                    proto.write(ActiveJob.RunningJob.EVALUATED_PRIORITY,
+                            evaluateJobPriorityLocked(jsc.getRunningJobLocked()));
+
+                    proto.write(ActiveJob.RunningJob.TIME_SINCE_MADE_ACTIVE_MS,
+                            nowUptime - job.madeActive);
+                    proto.write(ActiveJob.RunningJob.PENDING_DURATION_MS,
+                            job.madeActive - job.madePending);
+
+                    proto.end(rjToken);
+                }
+                proto.end(ajToken);
+            }
+            if (filterUid == -1) {
+                proto.write(JobSchedulerServiceDumpProto.IS_READY_TO_ROCK, mReadyToRock);
+                proto.write(JobSchedulerServiceDumpProto.REPORTED_ACTIVE, mReportedActive);
+            }
+            mConcurrencyManager.dumpProtoLocked(proto,
+                    JobSchedulerServiceDumpProto.CONCURRENCY_MANAGER, now, nowElapsed);
+        }
+
+        proto.flush();
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerShellCommand.java b/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerShellCommand.java
new file mode 100644
index 0000000..e361441
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerShellCommand.java
@@ -0,0 +1,436 @@
+/*
+ * Copyright (C) 2016 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 android.app.ActivityManager;
+import android.app.AppGlobals;
+import android.content.pm.IPackageManager;
+import android.content.pm.PackageManager;
+import android.os.Binder;
+import android.os.ShellCommand;
+import android.os.UserHandle;
+
+import java.io.PrintWriter;
+
+public final class JobSchedulerShellCommand extends ShellCommand {
+    public static final int CMD_ERR_NO_PACKAGE = -1000;
+    public static final int CMD_ERR_NO_JOB = -1001;
+    public static final int CMD_ERR_CONSTRAINTS = -1002;
+
+    JobSchedulerService mInternal;
+    IPackageManager mPM;
+
+    JobSchedulerShellCommand(JobSchedulerService service) {
+        mInternal = service;
+        mPM = AppGlobals.getPackageManager();
+    }
+
+    @Override
+    public int onCommand(String cmd) {
+        final PrintWriter pw = getOutPrintWriter();
+        try {
+            switch (cmd != null ? cmd : "") {
+                case "run":
+                    return runJob(pw);
+                case "timeout":
+                    return timeout(pw);
+                case "cancel":
+                    return cancelJob(pw);
+                case "monitor-battery":
+                    return monitorBattery(pw);
+                case "get-battery-seq":
+                    return getBatterySeq(pw);
+                case "get-battery-charging":
+                    return getBatteryCharging(pw);
+                case "get-battery-not-low":
+                    return getBatteryNotLow(pw);
+                case "get-storage-seq":
+                    return getStorageSeq(pw);
+                case "get-storage-not-low":
+                    return getStorageNotLow(pw);
+                case "get-job-state":
+                    return getJobState(pw);
+                case "heartbeat":
+                    return doHeartbeat(pw);
+                case "trigger-dock-state":
+                    return triggerDockState(pw);
+                default:
+                    return handleDefaultCommands(cmd);
+            }
+        } catch (Exception e) {
+            pw.println("Exception: " + e);
+        }
+        return -1;
+    }
+
+    private void checkPermission(String operation) throws Exception {
+        final int uid = Binder.getCallingUid();
+        if (uid == 0) {
+            // Root can do anything.
+            return;
+        }
+        final int perm = mPM.checkUidPermission(
+                "android.permission.CHANGE_APP_IDLE_STATE", uid);
+        if (perm != PackageManager.PERMISSION_GRANTED) {
+            throw new SecurityException("Uid " + uid
+                    + " not permitted to " + operation);
+        }
+    }
+
+    private boolean printError(int errCode, String pkgName, int userId, int jobId) {
+        PrintWriter pw;
+        switch (errCode) {
+            case CMD_ERR_NO_PACKAGE:
+                pw = getErrPrintWriter();
+                pw.print("Package not found: ");
+                pw.print(pkgName);
+                pw.print(" / user ");
+                pw.println(userId);
+                return true;
+
+            case CMD_ERR_NO_JOB:
+                pw = getErrPrintWriter();
+                pw.print("Could not find job ");
+                pw.print(jobId);
+                pw.print(" in package ");
+                pw.print(pkgName);
+                pw.print(" / user ");
+                pw.println(userId);
+                return true;
+
+            case CMD_ERR_CONSTRAINTS:
+                pw = getErrPrintWriter();
+                pw.print("Job ");
+                pw.print(jobId);
+                pw.print(" in package ");
+                pw.print(pkgName);
+                pw.print(" / user ");
+                pw.print(userId);
+                pw.println(" has functional constraints but --force not specified");
+                return true;
+
+            default:
+                return false;
+        }
+    }
+
+    private int runJob(PrintWriter pw) throws Exception {
+        checkPermission("force scheduled jobs");
+
+        boolean force = false;
+        int userId = UserHandle.USER_SYSTEM;
+
+        String opt;
+        while ((opt = getNextOption()) != null) {
+            switch (opt) {
+                case "-f":
+                case "--force":
+                    force = true;
+                    break;
+
+                case "-u":
+                case "--user":
+                    userId = Integer.parseInt(getNextArgRequired());
+                    break;
+
+                default:
+                    pw.println("Error: unknown option '" + opt + "'");
+                    return -1;
+            }
+        }
+
+        final String pkgName = getNextArgRequired();
+        final int jobId = Integer.parseInt(getNextArgRequired());
+
+        final long ident = Binder.clearCallingIdentity();
+        try {
+            int ret = mInternal.executeRunCommand(pkgName, userId, jobId, force);
+            if (printError(ret, pkgName, userId, jobId)) {
+                return ret;
+            }
+
+            // success!
+            pw.print("Running job");
+            if (force) {
+                pw.print(" [FORCED]");
+            }
+            pw.println();
+
+            return ret;
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+    }
+
+    private int timeout(PrintWriter pw) throws Exception {
+        checkPermission("force timeout jobs");
+
+        int userId = UserHandle.USER_ALL;
+
+        String opt;
+        while ((opt = getNextOption()) != null) {
+            switch (opt) {
+                case "-u":
+                case "--user":
+                    userId = UserHandle.parseUserArg(getNextArgRequired());
+                    break;
+
+                default:
+                    pw.println("Error: unknown option '" + opt + "'");
+                    return -1;
+            }
+        }
+
+        if (userId == UserHandle.USER_CURRENT) {
+            userId = ActivityManager.getCurrentUser();
+        }
+
+        final String pkgName = getNextArg();
+        final String jobIdStr = getNextArg();
+        final int jobId = jobIdStr != null ? Integer.parseInt(jobIdStr) : -1;
+
+        final long ident = Binder.clearCallingIdentity();
+        try {
+            return mInternal.executeTimeoutCommand(pw, pkgName, userId, jobIdStr != null, jobId);
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+    }
+
+    private int cancelJob(PrintWriter pw) throws Exception {
+        checkPermission("cancel jobs");
+
+        int userId = UserHandle.USER_SYSTEM;
+
+        String opt;
+        while ((opt = getNextOption()) != null) {
+            switch (opt) {
+                case "-u":
+                case "--user":
+                    userId = UserHandle.parseUserArg(getNextArgRequired());
+                    break;
+
+                default:
+                    pw.println("Error: unknown option '" + opt + "'");
+                    return -1;
+            }
+        }
+
+        if (userId < 0) {
+            pw.println("Error: must specify a concrete user ID");
+            return -1;
+        }
+
+        final String pkgName = getNextArg();
+        final String jobIdStr = getNextArg();
+        final int jobId = jobIdStr != null ? Integer.parseInt(jobIdStr) : -1;
+
+        final long ident = Binder.clearCallingIdentity();
+        try {
+            return mInternal.executeCancelCommand(pw, pkgName, userId, jobIdStr != null, jobId);
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+    }
+
+    private int monitorBattery(PrintWriter pw) throws Exception {
+        checkPermission("change battery monitoring");
+        String opt = getNextArgRequired();
+        boolean enabled;
+        if ("on".equals(opt)) {
+            enabled = true;
+        } else if ("off".equals(opt)) {
+            enabled = false;
+        } else {
+            getErrPrintWriter().println("Error: unknown option " + opt);
+            return 1;
+        }
+        final long ident = Binder.clearCallingIdentity();
+        try {
+            mInternal.setMonitorBattery(enabled);
+            if (enabled) pw.println("Battery monitoring enabled");
+            else pw.println("Battery monitoring disabled");
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+        return 0;
+    }
+
+    private int getBatterySeq(PrintWriter pw) {
+        int seq = mInternal.getBatterySeq();
+        pw.println(seq);
+        return 0;
+    }
+
+    private int getBatteryCharging(PrintWriter pw) {
+        boolean val = mInternal.getBatteryCharging();
+        pw.println(val);
+        return 0;
+    }
+
+    private int getBatteryNotLow(PrintWriter pw) {
+        boolean val = mInternal.getBatteryNotLow();
+        pw.println(val);
+        return 0;
+    }
+
+    private int getStorageSeq(PrintWriter pw) {
+        int seq = mInternal.getStorageSeq();
+        pw.println(seq);
+        return 0;
+    }
+
+    private int getStorageNotLow(PrintWriter pw) {
+        boolean val = mInternal.getStorageNotLow();
+        pw.println(val);
+        return 0;
+    }
+
+    private int getJobState(PrintWriter pw) throws Exception {
+        checkPermission("force timeout jobs");
+
+        int userId = UserHandle.USER_SYSTEM;
+
+        String opt;
+        while ((opt = getNextOption()) != null) {
+            switch (opt) {
+                case "-u":
+                case "--user":
+                    userId = UserHandle.parseUserArg(getNextArgRequired());
+                    break;
+
+                default:
+                    pw.println("Error: unknown option '" + opt + "'");
+                    return -1;
+            }
+        }
+
+        if (userId == UserHandle.USER_CURRENT) {
+            userId = ActivityManager.getCurrentUser();
+        }
+
+        final String pkgName = getNextArgRequired();
+        final String jobIdStr = getNextArgRequired();
+        final int jobId = Integer.parseInt(jobIdStr);
+
+        final long ident = Binder.clearCallingIdentity();
+        try {
+            int ret = mInternal.getJobState(pw, pkgName, userId, jobId);
+            printError(ret, pkgName, userId, jobId);
+            return ret;
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+    }
+
+    private int doHeartbeat(PrintWriter pw) throws Exception {
+        checkPermission("manipulate scheduler heartbeat");
+
+        final String arg = getNextArg();
+        final int numBeats = (arg != null) ? Integer.parseInt(arg) : 0;
+
+        final long ident = Binder.clearCallingIdentity();
+        try {
+            return mInternal.executeHeartbeatCommand(pw, numBeats);
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+    }
+
+    private int triggerDockState(PrintWriter pw) throws Exception {
+        checkPermission("trigger wireless charging dock state");
+
+        final String opt = getNextArgRequired();
+        boolean idleState;
+        if ("idle".equals(opt)) {
+            idleState = true;
+        } else if ("active".equals(opt)) {
+            idleState = false;
+        } else {
+            getErrPrintWriter().println("Error: unknown option " + opt);
+            return 1;
+        }
+
+        final long ident = Binder.clearCallingIdentity();
+        try {
+            mInternal.triggerDockState(idleState);
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+        return 0;
+    }
+
+    @Override
+    public void onHelp() {
+        final PrintWriter pw = getOutPrintWriter();
+
+        pw.println("Job scheduler (jobscheduler) commands:");
+        pw.println("  help");
+        pw.println("    Print this help text.");
+        pw.println("  run [-f | --force] [-u | --user USER_ID] PACKAGE JOB_ID");
+        pw.println("    Trigger immediate execution of a specific scheduled job.");
+        pw.println("    Options:");
+        pw.println("      -f or --force: run the job even if technical constraints such as");
+        pw.println("         connectivity are not currently met");
+        pw.println("      -u or --user: specify which user's job is to be run; the default is");
+        pw.println("         the primary or system user");
+        pw.println("  timeout [-u | --user USER_ID] [PACKAGE] [JOB_ID]");
+        pw.println("    Trigger immediate timeout of currently executing jobs, as if their.");
+        pw.println("    execution timeout had expired.");
+        pw.println("    Options:");
+        pw.println("      -u or --user: specify which user's job is to be run; the default is");
+        pw.println("         all users");
+        pw.println("  cancel [-u | --user USER_ID] PACKAGE [JOB_ID]");
+        pw.println("    Cancel a scheduled job.  If a job ID is not supplied, all jobs scheduled");
+        pw.println("    by that package will be canceled.  USE WITH CAUTION.");
+        pw.println("    Options:");
+        pw.println("      -u or --user: specify which user's job is to be run; the default is");
+        pw.println("         the primary or system user");
+        pw.println("  heartbeat [num]");
+        pw.println("    With no argument, prints the current standby heartbeat.  With a positive");
+        pw.println("    argument, advances the standby heartbeat by that number.");
+        pw.println("  monitor-battery [on|off]");
+        pw.println("    Control monitoring of all battery changes.  Off by default.  Turning");
+        pw.println("    on makes get-battery-seq useful.");
+        pw.println("  get-battery-seq");
+        pw.println("    Return the last battery update sequence number that was received.");
+        pw.println("  get-battery-charging");
+        pw.println("    Return whether the battery is currently considered to be charging.");
+        pw.println("  get-battery-not-low");
+        pw.println("    Return whether the battery is currently considered to not be low.");
+        pw.println("  get-storage-seq");
+        pw.println("    Return the last storage update sequence number that was received.");
+        pw.println("  get-storage-not-low");
+        pw.println("    Return whether storage is currently considered to not be low.");
+        pw.println("  get-job-state [-u | --user USER_ID] PACKAGE JOB_ID");
+        pw.println("    Return the current state of a job, may be any combination of:");
+        pw.println("      pending: currently on the pending list, waiting to be active");
+        pw.println("      active: job is actively running");
+        pw.println("      user-stopped: job can't run because its user is stopped");
+        pw.println("      backing-up: job can't run because app is currently backing up its data");
+        pw.println("      no-component: job can't run because its component is not available");
+        pw.println("      ready: job is ready to run (all constraints satisfied or bypassed)");
+        pw.println("      waiting: if nothing else above is printed, job not ready to run");
+        pw.println("    Options:");
+        pw.println("      -u or --user: specify which user's job is to be run; the default is");
+        pw.println("         the primary or system user");
+        pw.println("  trigger-dock-state [idle|active]");
+        pw.println("    Trigger wireless charging dock state.  Active by default.");
+        pw.println();
+    }
+
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java b/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java
new file mode 100644
index 0000000..7da128f
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/JobServiceContext.java
@@ -0,0 +1,856 @@
+/*
+ * Copyright (C) 2014 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.server.job.JobSchedulerService.sElapsedRealtimeClock;
+
+import android.app.ActivityManager;
+import android.app.job.IJobCallback;
+import android.app.job.IJobService;
+import android.app.job.JobInfo;
+import android.app.job.JobParameters;
+import android.app.job.JobWorkItem;
+import android.app.usage.UsageStatsManagerInternal;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.Message;
+import android.os.PowerManager;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.os.WorkSource;
+import android.util.EventLog;
+import android.util.Slog;
+import android.util.TimeUtils;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.app.IBatteryStats;
+import com.android.server.EventLogTags;
+import com.android.server.LocalServices;
+import com.android.server.job.controllers.JobStatus;
+
+/**
+ * Handles client binding and lifecycle of a job. Jobs execute one at a time on an instance of this
+ * class.
+ *
+ * There are two important interactions into this class from the
+ * {@link com.android.server.job.JobSchedulerService}. To execute a job and to cancel a job.
+ * - Execution of a new job is handled by the {@link #mAvailable}. This bit is flipped once when a
+ * job lands, and again when it is complete.
+ * - Cancelling is trickier, because there are also interactions from the client. It's possible
+ * the {@link com.android.server.job.JobServiceContext.JobServiceHandler} tries to process a
+ * {@link #doCancelLocked} after the client has already finished. This is handled by having
+ * {@link com.android.server.job.JobServiceContext.JobServiceHandler#handleCancelLocked} check whether
+ * the context is still valid.
+ * To mitigate this, we avoid sending duplicate onStopJob()
+ * calls to the client after they've specified jobFinished().
+ */
+public final class JobServiceContext implements ServiceConnection {
+    private static final boolean DEBUG = JobSchedulerService.DEBUG;
+    private static final boolean DEBUG_STANDBY = JobSchedulerService.DEBUG_STANDBY;
+
+    private static final String TAG = "JobServiceContext";
+    /** Amount of time a job is allowed to execute for before being considered timed-out. */
+    public static final long EXECUTING_TIMESLICE_MILLIS = 10 * 60 * 1000;  // 10mins.
+    /** Amount of time the JobScheduler waits for the initial service launch+bind. */
+    private static final long OP_BIND_TIMEOUT_MILLIS = 18 * 1000;
+    /** Amount of time the JobScheduler will wait for a response from an app for a message. */
+    private static final long OP_TIMEOUT_MILLIS = 8 * 1000;
+
+    private static final String[] VERB_STRINGS = {
+            "VERB_BINDING", "VERB_STARTING", "VERB_EXECUTING", "VERB_STOPPING", "VERB_FINISHED"
+    };
+
+    // States that a job occupies while interacting with the client.
+    static final int VERB_BINDING = 0;
+    static final int VERB_STARTING = 1;
+    static final int VERB_EXECUTING = 2;
+    static final int VERB_STOPPING = 3;
+    static final int VERB_FINISHED = 4;
+
+    // Messages that result from interactions with the client service.
+    /** System timed out waiting for a response. */
+    private static final int MSG_TIMEOUT = 0;
+
+    public static final int NO_PREFERRED_UID = -1;
+
+    private final Handler mCallbackHandler;
+    /** Make callbacks to {@link JobSchedulerService} to inform on job completion status. */
+    private final JobCompletedListener mCompletedListener;
+    /** Used for service binding, etc. */
+    private final Context mContext;
+    private final Object mLock;
+    private final IBatteryStats mBatteryStats;
+    private final JobPackageTracker mJobPackageTracker;
+    private PowerManager.WakeLock mWakeLock;
+
+    // Execution state.
+    private JobParameters mParams;
+    @VisibleForTesting
+    int mVerb;
+    private boolean mCancelled;
+
+    /**
+     * All the information maintained about the job currently being executed.
+     *
+     * Any reads (dereferences) not done from the handler thread must be synchronized on
+     * {@link #mLock}.
+     * Writes can only be done from the handler thread, or {@link #executeRunnableJob(JobStatus)}.
+     */
+    private JobStatus mRunningJob;
+    private JobCallback mRunningCallback;
+    /** Used to store next job to run when current job is to be preempted. */
+    private int mPreferredUid;
+    IJobService service;
+
+    /**
+     * Whether this context is free. This is set to false at the start of execution, and reset to
+     * true when execution is complete.
+     */
+    @GuardedBy("mLock")
+    private boolean mAvailable;
+    /** Track start time. */
+    private long mExecutionStartTimeElapsed;
+    /** Track when job will timeout. */
+    private long mTimeoutElapsed;
+
+    // Debugging: reason this job was last stopped.
+    public String mStoppedReason;
+
+    // Debugging: time this job was last stopped.
+    public long mStoppedTime;
+
+    final class JobCallback extends IJobCallback.Stub {
+        public String mStoppedReason;
+        public long mStoppedTime;
+
+        @Override
+        public void acknowledgeStartMessage(int jobId, boolean ongoing) {
+            doAcknowledgeStartMessage(this, jobId, ongoing);
+        }
+
+        @Override
+        public void acknowledgeStopMessage(int jobId, boolean reschedule) {
+            doAcknowledgeStopMessage(this, jobId, reschedule);
+        }
+
+        @Override
+        public JobWorkItem dequeueWork(int jobId) {
+            return doDequeueWork(this, jobId);
+        }
+
+        @Override
+        public boolean completeWork(int jobId, int workId) {
+            return doCompleteWork(this, jobId, workId);
+        }
+
+        @Override
+        public void jobFinished(int jobId, boolean reschedule) {
+            doJobFinished(this, jobId, reschedule);
+        }
+    }
+
+    JobServiceContext(JobSchedulerService service, IBatteryStats batteryStats,
+            JobPackageTracker tracker, Looper looper) {
+        this(service.getContext(), service.getLock(), batteryStats, tracker, service, looper);
+    }
+
+    @VisibleForTesting
+    JobServiceContext(Context context, Object lock, IBatteryStats batteryStats,
+            JobPackageTracker tracker, JobCompletedListener completedListener, Looper looper) {
+        mContext = context;
+        mLock = lock;
+        mBatteryStats = batteryStats;
+        mJobPackageTracker = tracker;
+        mCallbackHandler = new JobServiceHandler(looper);
+        mCompletedListener = completedListener;
+        mAvailable = true;
+        mVerb = VERB_FINISHED;
+        mPreferredUid = NO_PREFERRED_UID;
+    }
+
+    /**
+     * Give a job to this context for execution. Callers must first check {@link #getRunningJobLocked()}
+     * and ensure it is null to make sure this is a valid context.
+     * @param job The status of the job that we are going to run.
+     * @return True if the job is valid and is running. False if the job cannot be executed.
+     */
+    boolean executeRunnableJob(JobStatus job) {
+        synchronized (mLock) {
+            if (!mAvailable) {
+                Slog.e(TAG, "Starting new runnable but context is unavailable > Error.");
+                return false;
+            }
+
+            mPreferredUid = NO_PREFERRED_UID;
+
+            mRunningJob = job;
+            mRunningCallback = new JobCallback();
+            final boolean isDeadlineExpired =
+                    job.hasDeadlineConstraint() &&
+                            (job.getLatestRunTimeElapsed() < sElapsedRealtimeClock.millis());
+            Uri[] triggeredUris = null;
+            if (job.changedUris != null) {
+                triggeredUris = new Uri[job.changedUris.size()];
+                job.changedUris.toArray(triggeredUris);
+            }
+            String[] triggeredAuthorities = null;
+            if (job.changedAuthorities != null) {
+                triggeredAuthorities = new String[job.changedAuthorities.size()];
+                job.changedAuthorities.toArray(triggeredAuthorities);
+            }
+            final JobInfo ji = job.getJob();
+            mParams = new JobParameters(mRunningCallback, job.getJobId(), ji.getExtras(),
+                    ji.getTransientExtras(), ji.getClipData(), ji.getClipGrantFlags(),
+                    isDeadlineExpired, triggeredUris, triggeredAuthorities, job.network);
+            mExecutionStartTimeElapsed = sElapsedRealtimeClock.millis();
+
+            final long whenDeferred = job.getWhenStandbyDeferred();
+            if (whenDeferred > 0) {
+                final long deferral = mExecutionStartTimeElapsed - whenDeferred;
+                EventLog.writeEvent(EventLogTags.JOB_DEFERRED_EXECUTION, deferral);
+                if (DEBUG_STANDBY) {
+                    StringBuilder sb = new StringBuilder(128);
+                    sb.append("Starting job deferred for standby by ");
+                    TimeUtils.formatDuration(deferral, sb);
+                    sb.append(" ms : ");
+                    sb.append(job.toShortString());
+                    Slog.v(TAG, sb.toString());
+                }
+            }
+
+            // Once we'e begun executing a job, we by definition no longer care whether
+            // it was inflated from disk with not-yet-coherent delay/deadline bounds.
+            job.clearPersistedUtcTimes();
+
+            mVerb = VERB_BINDING;
+            scheduleOpTimeOutLocked();
+            final Intent intent = new Intent().setComponent(job.getServiceComponent());
+            boolean binding = false;
+            try {
+                binding = mContext.bindServiceAsUser(intent, this,
+                        Context.BIND_AUTO_CREATE | Context.BIND_NOT_FOREGROUND
+                        | Context.BIND_NOT_PERCEPTIBLE,
+                        new UserHandle(job.getUserId()));
+            } catch (SecurityException e) {
+                // Some permission policy, for example INTERACT_ACROSS_USERS and
+                // android:singleUser, can result in a SecurityException being thrown from
+                // bindServiceAsUser().  If this happens, catch it and fail gracefully.
+                Slog.w(TAG, "Job service " + job.getServiceComponent().getShortClassName()
+                        + " cannot be executed: " + e.getMessage());
+                binding = false;
+            }
+            if (!binding) {
+                if (DEBUG) {
+                    Slog.d(TAG, job.getServiceComponent().getShortClassName() + " unavailable.");
+                }
+                mRunningJob = null;
+                mRunningCallback = null;
+                mParams = null;
+                mExecutionStartTimeElapsed = 0L;
+                mVerb = VERB_FINISHED;
+                removeOpTimeOutLocked();
+                return false;
+            }
+            mJobPackageTracker.noteActive(job);
+            try {
+                mBatteryStats.noteJobStart(job.getBatteryName(), job.getSourceUid(),
+                        job.getStandbyBucket(), job.getJobId());
+            } catch (RemoteException e) {
+                // Whatever.
+            }
+            final String jobPackage = job.getSourcePackageName();
+            final int jobUserId = job.getSourceUserId();
+            UsageStatsManagerInternal usageStats =
+                    LocalServices.getService(UsageStatsManagerInternal.class);
+            usageStats.setLastJobRunTime(jobPackage, jobUserId, mExecutionStartTimeElapsed);
+            JobSchedulerInternal jobScheduler =
+                    LocalServices.getService(JobSchedulerInternal.class);
+            jobScheduler.noteJobStart(jobPackage, jobUserId);
+            mAvailable = false;
+            mStoppedReason = null;
+            mStoppedTime = 0;
+            return true;
+        }
+    }
+
+    /**
+     * Used externally to query the running job. Will return null if there is no job running.
+     */
+    JobStatus getRunningJobLocked() {
+        return mRunningJob;
+    }
+
+    /**
+     * Used only for debugging. Will return <code>"&lt;null&gt;"</code> if there is no job running.
+     */
+    private String getRunningJobNameLocked() {
+        return mRunningJob != null ? mRunningJob.toShortString() : "<null>";
+    }
+
+    /** Called externally when a job that was scheduled for execution should be cancelled. */
+    @GuardedBy("mLock")
+    void cancelExecutingJobLocked(int reason, String debugReason) {
+        doCancelLocked(reason, debugReason);
+    }
+
+    @GuardedBy("mLock")
+    void preemptExecutingJobLocked() {
+        doCancelLocked(JobParameters.REASON_PREEMPT, "cancelled due to preemption");
+    }
+
+    int getPreferredUid() {
+        return mPreferredUid;
+    }
+
+    void clearPreferredUid() {
+        mPreferredUid = NO_PREFERRED_UID;
+    }
+
+    long getExecutionStartTimeElapsed() {
+        return mExecutionStartTimeElapsed;
+    }
+
+    long getTimeoutElapsed() {
+        return mTimeoutElapsed;
+    }
+
+    @GuardedBy("mLock")
+    boolean timeoutIfExecutingLocked(String pkgName, int userId, boolean matchJobId, int jobId,
+            String reason) {
+        final JobStatus executing = getRunningJobLocked();
+        if (executing != null && (userId == UserHandle.USER_ALL || userId == executing.getUserId())
+                && (pkgName == null || pkgName.equals(executing.getSourcePackageName()))
+                && (!matchJobId || jobId == executing.getJobId())) {
+            if (mVerb == VERB_EXECUTING) {
+                mParams.setStopReason(JobParameters.REASON_TIMEOUT, reason);
+                sendStopMessageLocked("force timeout from shell");
+                return true;
+            }
+        }
+        return false;
+    }
+
+    void doJobFinished(JobCallback cb, int jobId, boolean reschedule) {
+        doCallback(cb, reschedule, "app called jobFinished");
+    }
+
+    void doAcknowledgeStopMessage(JobCallback cb, int jobId, boolean reschedule) {
+        doCallback(cb, reschedule, null);
+    }
+
+    void doAcknowledgeStartMessage(JobCallback cb, int jobId, boolean ongoing) {
+        doCallback(cb, ongoing, "finished start");
+    }
+
+    JobWorkItem doDequeueWork(JobCallback cb, int jobId) {
+        final long ident = Binder.clearCallingIdentity();
+        try {
+            synchronized (mLock) {
+                assertCallerLocked(cb);
+                if (mVerb == VERB_STOPPING || mVerb == VERB_FINISHED) {
+                    // This job is either all done, or on its way out.  Either way, it
+                    // should not dispatch any more work.  We will pick up any remaining
+                    // work the next time we start the job again.
+                    return null;
+                }
+                final JobWorkItem work = mRunningJob.dequeueWorkLocked();
+                if (work == null && !mRunningJob.hasExecutingWorkLocked()) {
+                    // This will finish the job.
+                    doCallbackLocked(false, "last work dequeued");
+                }
+                return work;
+            }
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+    }
+
+    boolean doCompleteWork(JobCallback cb, int jobId, int workId) {
+        final long ident = Binder.clearCallingIdentity();
+        try {
+            synchronized (mLock) {
+                assertCallerLocked(cb);
+                return mRunningJob.completeWorkLocked(ActivityManager.getService(), workId);
+            }
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+    }
+
+    /**
+     * We acquire/release a wakelock on onServiceConnected/unbindService. This mirrors the work
+     * we intend to send to the client - we stop sending work when the service is unbound so until
+     * then we keep the wakelock.
+     * @param name The concrete component name of the service that has been connected.
+     * @param service The IBinder of the Service's communication channel,
+     */
+    @Override
+    public void onServiceConnected(ComponentName name, IBinder service) {
+        JobStatus runningJob;
+        synchronized (mLock) {
+            // This isn't strictly necessary b/c the JobServiceHandler is running on the main
+            // looper and at this point we can't get any binder callbacks from the client. Better
+            // safe than sorry.
+            runningJob = mRunningJob;
+
+            if (runningJob == null || !name.equals(runningJob.getServiceComponent())) {
+                closeAndCleanupJobLocked(true /* needsReschedule */,
+                        "connected for different component");
+                return;
+            }
+            this.service = IJobService.Stub.asInterface(service);
+            final PowerManager pm =
+                    (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
+            PowerManager.WakeLock wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK,
+                    runningJob.getTag());
+            wl.setWorkSource(deriveWorkSource(runningJob));
+            wl.setReferenceCounted(false);
+            wl.acquire();
+
+            // We use a new wakelock instance per job.  In rare cases there is a race between
+            // teardown following job completion/cancellation and new job service spin-up
+            // such that if we simply assign mWakeLock to be the new instance, we orphan
+            // the currently-live lock instead of cleanly replacing it.  Watch for this and
+            // explicitly fast-forward the release if we're in that situation.
+            if (mWakeLock != null) {
+                Slog.w(TAG, "Bound new job " + runningJob + " but live wakelock " + mWakeLock
+                        + " tag=" + mWakeLock.getTag());
+                mWakeLock.release();
+            }
+            mWakeLock = wl;
+            doServiceBoundLocked();
+        }
+    }
+
+    private WorkSource deriveWorkSource(JobStatus runningJob) {
+        final int jobUid = runningJob.getSourceUid();
+        if (WorkSource.isChainedBatteryAttributionEnabled(mContext)) {
+            WorkSource workSource = new WorkSource();
+            workSource.createWorkChain()
+                    .addNode(jobUid, null)
+                    .addNode(android.os.Process.SYSTEM_UID, "JobScheduler");
+            return workSource;
+        } else {
+            return new WorkSource(jobUid);
+        }
+    }
+
+    /** If the client service crashes we reschedule this job and clean up. */
+    @Override
+    public void onServiceDisconnected(ComponentName name) {
+        synchronized (mLock) {
+            closeAndCleanupJobLocked(true /* needsReschedule */, "unexpectedly disconnected");
+        }
+    }
+
+    /**
+     * This class is reused across different clients, and passes itself in as a callback. Check
+     * whether the client exercising the callback is the client we expect.
+     * @return True if the binder calling is coming from the client we expect.
+     */
+    private boolean verifyCallerLocked(JobCallback cb) {
+        if (mRunningCallback != cb) {
+            if (DEBUG) {
+                Slog.d(TAG, "Stale callback received, ignoring.");
+            }
+            return false;
+        }
+        return true;
+    }
+
+    private void assertCallerLocked(JobCallback cb) {
+        if (!verifyCallerLocked(cb)) {
+            StringBuilder sb = new StringBuilder(128);
+            sb.append("Caller no longer running");
+            if (cb.mStoppedReason != null) {
+                sb.append(", last stopped ");
+                TimeUtils.formatDuration(sElapsedRealtimeClock.millis() - cb.mStoppedTime, sb);
+                sb.append(" because: ");
+                sb.append(cb.mStoppedReason);
+            }
+            throw new SecurityException(sb.toString());
+        }
+    }
+
+    /**
+     * Scheduling of async messages (basically timeouts at this point).
+     */
+    private class JobServiceHandler extends Handler {
+        JobServiceHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message message) {
+            switch (message.what) {
+                case MSG_TIMEOUT:
+                    synchronized (mLock) {
+                        if (message.obj == mRunningCallback) {
+                            handleOpTimeoutLocked();
+                        } else {
+                            JobCallback jc = (JobCallback)message.obj;
+                            StringBuilder sb = new StringBuilder(128);
+                            sb.append("Ignoring timeout of no longer active job");
+                            if (jc.mStoppedReason != null) {
+                                sb.append(", stopped ");
+                                TimeUtils.formatDuration(sElapsedRealtimeClock.millis()
+                                        - jc.mStoppedTime, sb);
+                                sb.append(" because: ");
+                                sb.append(jc.mStoppedReason);
+                            }
+                            Slog.w(TAG, sb.toString());
+                        }
+                    }
+                    break;
+                default:
+                    Slog.e(TAG, "Unrecognised message: " + message);
+            }
+        }
+    }
+
+    @GuardedBy("mLock")
+    void doServiceBoundLocked() {
+        removeOpTimeOutLocked();
+        handleServiceBoundLocked();
+    }
+
+    void doCallback(JobCallback cb, boolean reschedule, String reason) {
+        final long ident = Binder.clearCallingIdentity();
+        try {
+            synchronized (mLock) {
+                if (!verifyCallerLocked(cb)) {
+                    return;
+                }
+                doCallbackLocked(reschedule, reason);
+            }
+        } finally {
+            Binder.restoreCallingIdentity(ident);
+        }
+    }
+
+    @GuardedBy("mLock")
+    void doCallbackLocked(boolean reschedule, String reason) {
+        if (DEBUG) {
+            Slog.d(TAG, "doCallback of : " + mRunningJob
+                    + " v:" + VERB_STRINGS[mVerb]);
+        }
+        removeOpTimeOutLocked();
+
+        if (mVerb == VERB_STARTING) {
+            handleStartedLocked(reschedule);
+        } else if (mVerb == VERB_EXECUTING ||
+                mVerb == VERB_STOPPING) {
+            handleFinishedLocked(reschedule, reason);
+        } else {
+            if (DEBUG) {
+                Slog.d(TAG, "Unrecognised callback: " + mRunningJob);
+            }
+        }
+    }
+
+    @GuardedBy("mLock")
+    void doCancelLocked(int arg1, String debugReason) {
+        if (mVerb == VERB_FINISHED) {
+            if (DEBUG) {
+                Slog.d(TAG,
+                        "Trying to process cancel for torn-down context, ignoring.");
+            }
+            return;
+        }
+        mParams.setStopReason(arg1, debugReason);
+        if (arg1 == JobParameters.REASON_PREEMPT) {
+            mPreferredUid = mRunningJob != null ? mRunningJob.getUid() :
+                    NO_PREFERRED_UID;
+        }
+        handleCancelLocked(debugReason);
+    }
+
+    /** Start the job on the service. */
+    @GuardedBy("mLock")
+    private void handleServiceBoundLocked() {
+        if (DEBUG) {
+            Slog.d(TAG, "handleServiceBound for " + getRunningJobNameLocked());
+        }
+        if (mVerb != VERB_BINDING) {
+            Slog.e(TAG, "Sending onStartJob for a job that isn't pending. "
+                    + VERB_STRINGS[mVerb]);
+            closeAndCleanupJobLocked(false /* reschedule */, "started job not pending");
+            return;
+        }
+        if (mCancelled) {
+            if (DEBUG) {
+                Slog.d(TAG, "Job cancelled while waiting for bind to complete. "
+                        + mRunningJob);
+            }
+            closeAndCleanupJobLocked(true /* reschedule */, "cancelled while waiting for bind");
+            return;
+        }
+        try {
+            mVerb = VERB_STARTING;
+            scheduleOpTimeOutLocked();
+            service.startJob(mParams);
+        } catch (Exception e) {
+            // We catch 'Exception' because client-app malice or bugs might induce a wide
+            // range of possible exception-throw outcomes from startJob() and its handling
+            // of the client's ParcelableBundle extras.
+            Slog.e(TAG, "Error sending onStart message to '" +
+                    mRunningJob.getServiceComponent().getShortClassName() + "' ", e);
+        }
+    }
+
+    /**
+     * State behaviours.
+     * VERB_STARTING   -> Successful start, change job to VERB_EXECUTING and post timeout.
+     *     _PENDING    -> Error
+     *     _EXECUTING  -> Error
+     *     _STOPPING   -> Error
+     */
+    @GuardedBy("mLock")
+    private void handleStartedLocked(boolean workOngoing) {
+        switch (mVerb) {
+            case VERB_STARTING:
+                mVerb = VERB_EXECUTING;
+                if (!workOngoing) {
+                    // Job is finished already so fast-forward to handleFinished.
+                    handleFinishedLocked(false, "onStartJob returned false");
+                    return;
+                }
+                if (mCancelled) {
+                    if (DEBUG) {
+                        Slog.d(TAG, "Job cancelled while waiting for onStartJob to complete.");
+                    }
+                    // Cancelled *while* waiting for acknowledgeStartMessage from client.
+                    handleCancelLocked(null);
+                    return;
+                }
+                scheduleOpTimeOutLocked();
+                break;
+            default:
+                Slog.e(TAG, "Handling started job but job wasn't starting! Was "
+                        + VERB_STRINGS[mVerb] + ".");
+                return;
+        }
+    }
+
+    /**
+     * VERB_EXECUTING  -> Client called jobFinished(), clean up and notify done.
+     *     _STOPPING   -> Successful finish, clean up and notify done.
+     *     _STARTING   -> Error
+     *     _PENDING    -> Error
+     */
+    @GuardedBy("mLock")
+    private void handleFinishedLocked(boolean reschedule, String reason) {
+        switch (mVerb) {
+            case VERB_EXECUTING:
+            case VERB_STOPPING:
+                closeAndCleanupJobLocked(reschedule, reason);
+                break;
+            default:
+                Slog.e(TAG, "Got an execution complete message for a job that wasn't being" +
+                        "executed. Was " + VERB_STRINGS[mVerb] + ".");
+        }
+    }
+
+    /**
+     * A job can be in various states when a cancel request comes in:
+     * VERB_BINDING    -> Cancelled before bind completed. Mark as cancelled and wait for
+     *                    {@link #onServiceConnected(android.content.ComponentName, android.os.IBinder)}
+     *     _STARTING   -> Mark as cancelled and wait for
+     *                    {@link JobServiceContext#doAcknowledgeStartMessage}
+     *     _EXECUTING  -> call {@link #sendStopMessageLocked}}, but only if there are no callbacks
+     *                      in the message queue.
+     *     _ENDING     -> No point in doing anything here, so we ignore.
+     */
+    @GuardedBy("mLock")
+    private void handleCancelLocked(String reason) {
+        if (JobSchedulerService.DEBUG) {
+            Slog.d(TAG, "Handling cancel for: " + mRunningJob.getJobId() + " "
+                    + VERB_STRINGS[mVerb]);
+        }
+        switch (mVerb) {
+            case VERB_BINDING:
+            case VERB_STARTING:
+                mCancelled = true;
+                applyStoppedReasonLocked(reason);
+                break;
+            case VERB_EXECUTING:
+                sendStopMessageLocked(reason);
+                break;
+            case VERB_STOPPING:
+                // Nada.
+                break;
+            default:
+                Slog.e(TAG, "Cancelling a job without a valid verb: " + mVerb);
+                break;
+        }
+    }
+
+    /** Process MSG_TIMEOUT here. */
+    @GuardedBy("mLock")
+    private void handleOpTimeoutLocked() {
+        switch (mVerb) {
+            case VERB_BINDING:
+                Slog.w(TAG, "Time-out while trying to bind " + getRunningJobNameLocked()
+                        + ", dropping.");
+                closeAndCleanupJobLocked(false /* needsReschedule */, "timed out while binding");
+                break;
+            case VERB_STARTING:
+                // Client unresponsive - wedged or failed to respond in time. We don't really
+                // know what happened so let's log it and notify the JobScheduler
+                // FINISHED/NO-RETRY.
+                Slog.w(TAG, "No response from client for onStartJob "
+                        + getRunningJobNameLocked());
+                closeAndCleanupJobLocked(false /* needsReschedule */, "timed out while starting");
+                break;
+            case VERB_STOPPING:
+                // At least we got somewhere, so fail but ask the JobScheduler to reschedule.
+                Slog.w(TAG, "No response from client for onStopJob "
+                        + getRunningJobNameLocked());
+                closeAndCleanupJobLocked(true /* needsReschedule */, "timed out while stopping");
+                break;
+            case VERB_EXECUTING:
+                // Not an error - client ran out of time.
+                Slog.i(TAG, "Client timed out while executing (no jobFinished received), " +
+                        "sending onStop: " + getRunningJobNameLocked());
+                mParams.setStopReason(JobParameters.REASON_TIMEOUT, "client timed out");
+                sendStopMessageLocked("timeout while executing");
+                break;
+            default:
+                Slog.e(TAG, "Handling timeout for an invalid job state: "
+                        + getRunningJobNameLocked() + ", dropping.");
+                closeAndCleanupJobLocked(false /* needsReschedule */, "invalid timeout");
+        }
+    }
+
+    /**
+     * Already running, need to stop. Will switch {@link #mVerb} from VERB_EXECUTING ->
+     * VERB_STOPPING.
+     */
+    @GuardedBy("mLock")
+    private void sendStopMessageLocked(String reason) {
+        removeOpTimeOutLocked();
+        if (mVerb != VERB_EXECUTING) {
+            Slog.e(TAG, "Sending onStopJob for a job that isn't started. " + mRunningJob);
+            closeAndCleanupJobLocked(false /* reschedule */, reason);
+            return;
+        }
+        try {
+            applyStoppedReasonLocked(reason);
+            mVerb = VERB_STOPPING;
+            scheduleOpTimeOutLocked();
+            service.stopJob(mParams);
+        } catch (RemoteException e) {
+            Slog.e(TAG, "Error sending onStopJob to client.", e);
+            // The job's host app apparently crashed during the job, so we should reschedule.
+            closeAndCleanupJobLocked(true /* reschedule */, "host crashed when trying to stop");
+        }
+    }
+
+    /**
+     * The provided job has finished, either by calling
+     * {@link android.app.job.JobService#jobFinished(android.app.job.JobParameters, boolean)}
+     * or from acknowledging the stop message we sent. Either way, we're done tracking it and
+     * we want to clean up internally.
+     */
+    @GuardedBy("mLock")
+    private void closeAndCleanupJobLocked(boolean reschedule, String reason) {
+        final JobStatus completedJob;
+        if (mVerb == VERB_FINISHED) {
+            return;
+        }
+        applyStoppedReasonLocked(reason);
+        completedJob = mRunningJob;
+        mJobPackageTracker.noteInactive(completedJob, mParams.getStopReason(), reason);
+        try {
+            mBatteryStats.noteJobFinish(mRunningJob.getBatteryName(),
+                    mRunningJob.getSourceUid(), mParams.getStopReason(),
+                    mRunningJob.getStandbyBucket(), mRunningJob.getJobId());
+        } catch (RemoteException e) {
+            // Whatever.
+        }
+        if (mWakeLock != null) {
+            mWakeLock.release();
+        }
+        mContext.unbindService(JobServiceContext.this);
+        mWakeLock = null;
+        mRunningJob = null;
+        mRunningCallback = null;
+        mParams = null;
+        mVerb = VERB_FINISHED;
+        mCancelled = false;
+        service = null;
+        mAvailable = true;
+        removeOpTimeOutLocked();
+        mCompletedListener.onJobCompletedLocked(completedJob, reschedule);
+    }
+
+    private void applyStoppedReasonLocked(String reason) {
+        if (reason != null && mStoppedReason == null) {
+            mStoppedReason = reason;
+            mStoppedTime = sElapsedRealtimeClock.millis();
+            if (mRunningCallback != null) {
+                mRunningCallback.mStoppedReason = mStoppedReason;
+                mRunningCallback.mStoppedTime = mStoppedTime;
+            }
+        }
+    }
+
+    /**
+     * Called when sending a message to the client, over whose execution we have no control. If
+     * we haven't received a response in a certain amount of time, we want to give up and carry
+     * on with life.
+     */
+    private void scheduleOpTimeOutLocked() {
+        removeOpTimeOutLocked();
+
+        final long timeoutMillis;
+        switch (mVerb) {
+            case VERB_EXECUTING:
+                timeoutMillis = EXECUTING_TIMESLICE_MILLIS;
+                break;
+
+            case VERB_BINDING:
+                timeoutMillis = OP_BIND_TIMEOUT_MILLIS;
+                break;
+
+            default:
+                timeoutMillis = OP_TIMEOUT_MILLIS;
+                break;
+        }
+        if (DEBUG) {
+            Slog.d(TAG, "Scheduling time out for '" +
+                    mRunningJob.getServiceComponent().getShortClassName() + "' jId: " +
+                    mParams.getJobId() + ", in " + (timeoutMillis / 1000) + " s");
+        }
+        Message m = mCallbackHandler.obtainMessage(MSG_TIMEOUT, mRunningCallback);
+        mCallbackHandler.sendMessageDelayed(m, timeoutMillis);
+        mTimeoutElapsed = sElapsedRealtimeClock.millis() + timeoutMillis;
+    }
+
+
+    private void removeOpTimeOutLocked() {
+        mCallbackHandler.removeMessages(MSG_TIMEOUT);
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/JobStore.java b/apex/jobscheduler/service/java/com/android/server/job/JobStore.java
new file mode 100644
index 0000000..d69faf3
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/JobStore.java
@@ -0,0 +1,1285 @@
+/*
+ * Copyright (C) 2014 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.server.job.JobSchedulerService.sElapsedRealtimeClock;
+import static com.android.server.job.JobSchedulerService.sSystemClock;
+
+import android.annotation.Nullable;
+import android.app.ActivityManager;
+import android.app.IActivityManager;
+import android.app.job.JobInfo;
+import android.content.ComponentName;
+import android.content.Context;
+import android.net.NetworkRequest;
+import android.os.Environment;
+import android.os.Handler;
+import android.os.PersistableBundle;
+import android.os.Process;
+import android.os.SystemClock;
+import android.os.UserHandle;
+import android.text.format.DateUtils;
+import android.util.ArraySet;
+import android.util.AtomicFile;
+import android.util.Pair;
+import android.util.Slog;
+import android.util.SparseArray;
+import android.util.Xml;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.ArrayUtils;
+import com.android.internal.util.BitUtils;
+import com.android.internal.util.FastXmlSerializer;
+import com.android.server.IoThread;
+import com.android.server.LocalServices;
+import com.android.server.job.JobSchedulerInternal.JobStorePersistStats;
+import com.android.server.job.controllers.JobStatus;
+
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlSerializer;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * Maintains the master list of jobs that the job scheduler is tracking. These jobs are compared by
+ * reference, so none of the functions in this class should make a copy.
+ * Also handles read/write of persisted jobs.
+ *
+ * Note on locking:
+ *      All callers to this class must <strong>lock on the class object they are calling</strong>.
+ *      This is important b/c {@link com.android.server.job.JobStore.WriteJobsMapToDiskRunnable}
+ *      and {@link com.android.server.job.JobStore.ReadJobMapFromDiskRunnable} lock on that
+ *      object.
+ *
+ * Test:
+ * atest $ANDROID_BUILD_TOP/frameworks/base/services/tests/servicestests/src/com/android/server/job/JobStoreTest.java
+ */
+public final class JobStore {
+    private static final String TAG = "JobStore";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG;
+
+    /** Threshold to adjust how often we want to write to the db. */
+    private static final long JOB_PERSIST_DELAY = 2000L;
+
+    final Object mLock;
+    final Object mWriteScheduleLock;    // used solely for invariants around write scheduling
+    final JobSet mJobSet; // per-caller-uid and per-source-uid tracking
+    final Context mContext;
+
+    // Bookkeeping around incorrect boot-time system clock
+    private final long mXmlTimestamp;
+    private boolean mRtcGood;
+
+    @GuardedBy("mWriteScheduleLock")
+    private boolean mWriteScheduled;
+
+    @GuardedBy("mWriteScheduleLock")
+    private boolean mWriteInProgress;
+
+    private static final Object sSingletonLock = new Object();
+    private final AtomicFile mJobsFile;
+    /** Handler backed by IoThread for writing to disk. */
+    private final Handler mIoHandler = IoThread.getHandler();
+    private static JobStore sSingleton;
+
+    private JobStorePersistStats mPersistInfo = new JobStorePersistStats();
+
+    /** Used by the {@link JobSchedulerService} to instantiate the JobStore. */
+    static JobStore initAndGet(JobSchedulerService jobManagerService) {
+        synchronized (sSingletonLock) {
+            if (sSingleton == null) {
+                sSingleton = new JobStore(jobManagerService.getContext(),
+                        jobManagerService.getLock(), Environment.getDataDirectory());
+            }
+            return sSingleton;
+        }
+    }
+
+    /**
+     * @return A freshly initialized job store object, with no loaded jobs.
+     */
+    @VisibleForTesting
+    public static JobStore initAndGetForTesting(Context context, File dataDir) {
+        JobStore jobStoreUnderTest = new JobStore(context, new Object(), dataDir);
+        jobStoreUnderTest.clear();
+        return jobStoreUnderTest;
+    }
+
+    /**
+     * Construct the instance of the job store. This results in a blocking read from disk.
+     */
+    private JobStore(Context context, Object lock, File dataDir) {
+        mLock = lock;
+        mWriteScheduleLock = new Object();
+        mContext = context;
+
+        File systemDir = new File(dataDir, "system");
+        File jobDir = new File(systemDir, "job");
+        jobDir.mkdirs();
+        mJobsFile = new AtomicFile(new File(jobDir, "jobs.xml"), "jobs");
+
+        mJobSet = new JobSet();
+
+        // If the current RTC is earlier than the timestamp on our persisted jobs file,
+        // we suspect that the RTC is uninitialized and so we cannot draw conclusions
+        // about persisted job scheduling.
+        //
+        // Note that if the persisted jobs file does not exist, we proceed with the
+        // assumption that the RTC is good.  This is less work and is safe: if the
+        // clock updates to sanity then we'll be saving the persisted jobs file in that
+        // correct state, which is normal; or we'll wind up writing the jobs file with
+        // an incorrect historical timestamp.  That's fine; at worst we'll reboot with
+        // a *correct* timestamp, see a bunch of overdue jobs, and run them; then
+        // settle into normal operation.
+        mXmlTimestamp = mJobsFile.getLastModifiedTime();
+        mRtcGood = (sSystemClock.millis() > mXmlTimestamp);
+
+        readJobMapFromDisk(mJobSet, mRtcGood);
+    }
+
+    public boolean jobTimesInflatedValid() {
+        return mRtcGood;
+    }
+
+    public boolean clockNowValidToInflate(long now) {
+        return now >= mXmlTimestamp;
+    }
+
+    /**
+     * Find all the jobs that were affected by RTC clock uncertainty at boot time.  Returns
+     * parallel lists of the existing JobStatus objects and of new, equivalent JobStatus instances
+     * with now-corrected time bounds.
+     */
+    public void getRtcCorrectedJobsLocked(final ArrayList<JobStatus> toAdd,
+            final ArrayList<JobStatus> toRemove) {
+        final long elapsedNow = sElapsedRealtimeClock.millis();
+        final IActivityManager am = ActivityManager.getService();
+
+        // Find the jobs that need to be fixed up, collecting them for post-iteration
+        // replacement with their new versions
+        forEachJob(job -> {
+            final Pair<Long, Long> utcTimes = job.getPersistedUtcTimes();
+            if (utcTimes != null) {
+                Pair<Long, Long> elapsedRuntimes =
+                        convertRtcBoundsToElapsed(utcTimes, elapsedNow);
+                JobStatus newJob = new JobStatus(job, job.getBaseHeartbeat(),
+                        elapsedRuntimes.first, elapsedRuntimes.second,
+                        0, job.getLastSuccessfulRunTime(), job.getLastFailedRunTime());
+                newJob.prepareLocked(am);
+                toAdd.add(newJob);
+                toRemove.add(job);
+            }
+        });
+    }
+
+    /**
+     * Add a job to the master list, persisting it if necessary. If the JobStatus already exists,
+     * it will be replaced.
+     * @param jobStatus Job to add.
+     * @return Whether or not an equivalent JobStatus was replaced by this operation.
+     */
+    public boolean add(JobStatus jobStatus) {
+        boolean replaced = mJobSet.remove(jobStatus);
+        mJobSet.add(jobStatus);
+        if (jobStatus.isPersisted()) {
+            maybeWriteStatusToDiskAsync();
+        }
+        if (DEBUG) {
+            Slog.d(TAG, "Added job status to store: " + jobStatus);
+        }
+        return replaced;
+    }
+
+    boolean containsJob(JobStatus jobStatus) {
+        return mJobSet.contains(jobStatus);
+    }
+
+    public int size() {
+        return mJobSet.size();
+    }
+
+    public JobStorePersistStats getPersistStats() {
+        return mPersistInfo;
+    }
+
+    public int countJobsForUid(int uid) {
+        return mJobSet.countJobsForUid(uid);
+    }
+
+    /**
+     * Remove the provided job. Will also delete the job if it was persisted.
+     * @param removeFromPersisted If true, the job will be removed from the persisted job list
+     *                            immediately (if it was persisted).
+     * @return Whether or not the job existed to be removed.
+     */
+    public boolean remove(JobStatus jobStatus, boolean removeFromPersisted) {
+        boolean removed = mJobSet.remove(jobStatus);
+        if (!removed) {
+            if (DEBUG) {
+                Slog.d(TAG, "Couldn't remove job: didn't exist: " + jobStatus);
+            }
+            return false;
+        }
+        if (removeFromPersisted && jobStatus.isPersisted()) {
+            maybeWriteStatusToDiskAsync();
+        }
+        return removed;
+    }
+
+    /**
+     * Remove the jobs of users not specified in the whitelist.
+     * @param whitelist Array of User IDs whose jobs are not to be removed.
+     */
+    public void removeJobsOfNonUsers(int[] whitelist) {
+        mJobSet.removeJobsOfNonUsers(whitelist);
+    }
+
+    @VisibleForTesting
+    public void clear() {
+        mJobSet.clear();
+        maybeWriteStatusToDiskAsync();
+    }
+
+    /**
+     * @param userHandle User for whom we are querying the list of jobs.
+     * @return A list of all the jobs scheduled for the provided user. Never null.
+     */
+    public List<JobStatus> getJobsByUser(int userHandle) {
+        return mJobSet.getJobsByUser(userHandle);
+    }
+
+    /**
+     * @param uid Uid of the requesting app.
+     * @return All JobStatus objects for a given uid from the master list. Never null.
+     */
+    public List<JobStatus> getJobsByUid(int uid) {
+        return mJobSet.getJobsByUid(uid);
+    }
+
+    /**
+     * @param uid Uid of the requesting app.
+     * @param jobId Job id, specified at schedule-time.
+     * @return the JobStatus that matches the provided uId and jobId, or null if none found.
+     */
+    public JobStatus getJobByUidAndJobId(int uid, int jobId) {
+        return mJobSet.get(uid, jobId);
+    }
+
+    /**
+     * Iterate over the set of all jobs, invoking the supplied functor on each.  This is for
+     * customers who need to examine each job; we'd much rather not have to generate
+     * transient unified collections for them to iterate over and then discard, or creating
+     * iterators every time a client needs to perform a sweep.
+     */
+    public void forEachJob(Consumer<JobStatus> functor) {
+        mJobSet.forEachJob(null, functor);
+    }
+
+    public void forEachJob(@Nullable Predicate<JobStatus> filterPredicate,
+            Consumer<JobStatus> functor) {
+        mJobSet.forEachJob(filterPredicate, functor);
+    }
+
+    public void forEachJob(int uid, Consumer<JobStatus> functor) {
+        mJobSet.forEachJob(uid, functor);
+    }
+
+    public void forEachJobForSourceUid(int sourceUid, Consumer<JobStatus> functor) {
+        mJobSet.forEachJobForSourceUid(sourceUid, functor);
+    }
+
+    /** Version of the db schema. */
+    private static final int JOBS_FILE_VERSION = 0;
+    /** Tag corresponds to constraints this job needs. */
+    private static final String XML_TAG_PARAMS_CONSTRAINTS = "constraints";
+    /** Tag corresponds to execution parameters. */
+    private static final String XML_TAG_PERIODIC = "periodic";
+    private static final String XML_TAG_ONEOFF = "one-off";
+    private static final String XML_TAG_EXTRAS = "extras";
+
+    /**
+     * Every time the state changes we write all the jobs in one swath, instead of trying to
+     * track incremental changes.
+     */
+    private void maybeWriteStatusToDiskAsync() {
+        synchronized (mWriteScheduleLock) {
+            if (!mWriteScheduled) {
+                if (DEBUG) {
+                    Slog.v(TAG, "Scheduling persist of jobs to disk.");
+                }
+                mIoHandler.postDelayed(mWriteRunnable, JOB_PERSIST_DELAY);
+                mWriteScheduled = mWriteInProgress = true;
+            }
+        }
+    }
+
+    @VisibleForTesting
+    public void readJobMapFromDisk(JobSet jobSet, boolean rtcGood) {
+        new ReadJobMapFromDiskRunnable(jobSet, rtcGood).run();
+    }
+
+    /** Write persisted JobStore state to disk synchronously. Should only be used for testing. */
+    @VisibleForTesting
+    public void writeStatusToDiskForTesting() {
+        synchronized (mWriteScheduleLock) {
+            if (mWriteScheduled) {
+                throw new IllegalStateException("An asynchronous write is already scheduled.");
+            }
+
+            mWriteScheduled = mWriteInProgress = true;
+            mWriteRunnable.run();
+        }
+    }
+
+    /**
+     * Wait for any pending write to the persistent store to clear
+     * @param maxWaitMillis Maximum time from present to wait
+     * @return {@code true} if I/O cleared as expected, {@code false} if the wait
+     *     timed out before the pending write completed.
+     */
+    @VisibleForTesting
+    public boolean waitForWriteToCompleteForTesting(long maxWaitMillis) {
+        final long start = SystemClock.uptimeMillis();
+        final long end = start + maxWaitMillis;
+        synchronized (mWriteScheduleLock) {
+            while (mWriteInProgress) {
+                final long now = SystemClock.uptimeMillis();
+                if (now >= end) {
+                    // still not done and we've hit the end; failure
+                    return false;
+                }
+                try {
+                    mWriteScheduleLock.wait(now - start + maxWaitMillis);
+                } catch (InterruptedException e) {
+                    // Spurious; keep waiting
+                    break;
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Runnable that writes {@link #mJobSet} out to xml.
+     * NOTE: This Runnable locks on mLock
+     */
+    private final Runnable mWriteRunnable = new Runnable() {
+        @Override
+        public void run() {
+            final long startElapsed = sElapsedRealtimeClock.millis();
+            final List<JobStatus> storeCopy = new ArrayList<JobStatus>();
+            // Intentionally allow new scheduling of a write operation *before* we clone
+            // the job set.  If we reset it to false after cloning, there's a window in
+            // which no new write will be scheduled but mLock is not held, i.e. a new
+            // job might appear and fail to be recognized as needing a persist.  The
+            // potential cost is one redundant write of an identical set of jobs in the
+            // rare case of that specific race, but by doing it this way we avoid quite
+            // a bit of lock contention.
+            synchronized (mWriteScheduleLock) {
+                mWriteScheduled = false;
+            }
+            synchronized (mLock) {
+                // Clone the jobs so we can release the lock before writing.
+                mJobSet.forEachJob(null, (job) -> {
+                    if (job.isPersisted()) {
+                        storeCopy.add(new JobStatus(job));
+                    }
+                });
+            }
+            writeJobsMapImpl(storeCopy);
+            if (DEBUG) {
+                Slog.v(TAG, "Finished writing, took " + (sElapsedRealtimeClock.millis()
+                        - startElapsed) + "ms");
+            }
+            synchronized (mWriteScheduleLock) {
+                mWriteInProgress = false;
+                mWriteScheduleLock.notifyAll();
+            }
+        }
+
+        private void writeJobsMapImpl(List<JobStatus> jobList) {
+            int numJobs = 0;
+            int numSystemJobs = 0;
+            int numSyncJobs = 0;
+            try {
+                final long startTime = SystemClock.uptimeMillis();
+                ByteArrayOutputStream baos = new ByteArrayOutputStream();
+                XmlSerializer out = new FastXmlSerializer();
+                out.setOutput(baos, StandardCharsets.UTF_8.name());
+                out.startDocument(null, true);
+                out.setFeature("http://xmlpull.org/v1/doc/features.html#indent-output", true);
+
+                out.startTag(null, "job-info");
+                out.attribute(null, "version", Integer.toString(JOBS_FILE_VERSION));
+                for (int i=0; i<jobList.size(); i++) {
+                    JobStatus jobStatus = jobList.get(i);
+                    if (DEBUG) {
+                        Slog.d(TAG, "Saving job " + jobStatus.getJobId());
+                    }
+                    out.startTag(null, "job");
+                    addAttributesToJobTag(out, jobStatus);
+                    writeConstraintsToXml(out, jobStatus);
+                    writeExecutionCriteriaToXml(out, jobStatus);
+                    writeBundleToXml(jobStatus.getJob().getExtras(), out);
+                    out.endTag(null, "job");
+
+                    numJobs++;
+                    if (jobStatus.getUid() == Process.SYSTEM_UID) {
+                        numSystemJobs++;
+                        if (isSyncJob(jobStatus)) {
+                            numSyncJobs++;
+                        }
+                    }
+                }
+                out.endTag(null, "job-info");
+                out.endDocument();
+
+                // Write out to disk in one fell swoop.
+                FileOutputStream fos = mJobsFile.startWrite(startTime);
+                fos.write(baos.toByteArray());
+                mJobsFile.finishWrite(fos);
+            } catch (IOException e) {
+                if (DEBUG) {
+                    Slog.v(TAG, "Error writing out job data.", e);
+                }
+            } catch (XmlPullParserException e) {
+                if (DEBUG) {
+                    Slog.d(TAG, "Error persisting bundle.", e);
+                }
+            } finally {
+                mPersistInfo.countAllJobsSaved = numJobs;
+                mPersistInfo.countSystemServerJobsSaved = numSystemJobs;
+                mPersistInfo.countSystemSyncManagerJobsSaved = numSyncJobs;
+            }
+        }
+
+        /** Write out a tag with data comprising the required fields and priority of this job and
+         * its client.
+         */
+        private void addAttributesToJobTag(XmlSerializer out, JobStatus jobStatus)
+                throws IOException {
+            out.attribute(null, "jobid", Integer.toString(jobStatus.getJobId()));
+            out.attribute(null, "package", jobStatus.getServiceComponent().getPackageName());
+            out.attribute(null, "class", jobStatus.getServiceComponent().getClassName());
+            if (jobStatus.getSourcePackageName() != null) {
+                out.attribute(null, "sourcePackageName", jobStatus.getSourcePackageName());
+            }
+            if (jobStatus.getSourceTag() != null) {
+                out.attribute(null, "sourceTag", jobStatus.getSourceTag());
+            }
+            out.attribute(null, "sourceUserId", String.valueOf(jobStatus.getSourceUserId()));
+            out.attribute(null, "uid", Integer.toString(jobStatus.getUid()));
+            out.attribute(null, "priority", String.valueOf(jobStatus.getPriority()));
+            out.attribute(null, "flags", String.valueOf(jobStatus.getFlags()));
+            if (jobStatus.getInternalFlags() != 0) {
+                out.attribute(null, "internalFlags", String.valueOf(jobStatus.getInternalFlags()));
+            }
+
+            out.attribute(null, "lastSuccessfulRunTime",
+                    String.valueOf(jobStatus.getLastSuccessfulRunTime()));
+            out.attribute(null, "lastFailedRunTime",
+                    String.valueOf(jobStatus.getLastFailedRunTime()));
+        }
+
+        private void writeBundleToXml(PersistableBundle extras, XmlSerializer out)
+                throws IOException, XmlPullParserException {
+            out.startTag(null, XML_TAG_EXTRAS);
+            PersistableBundle extrasCopy = deepCopyBundle(extras, 10);
+            extrasCopy.saveToXml(out);
+            out.endTag(null, XML_TAG_EXTRAS);
+        }
+
+        private PersistableBundle deepCopyBundle(PersistableBundle bundle, int maxDepth) {
+            if (maxDepth <= 0) {
+                return null;
+            }
+            PersistableBundle copy = (PersistableBundle) bundle.clone();
+            Set<String> keySet = bundle.keySet();
+            for (String key: keySet) {
+                Object o = copy.get(key);
+                if (o instanceof PersistableBundle) {
+                    PersistableBundle bCopy = deepCopyBundle((PersistableBundle) o, maxDepth-1);
+                    copy.putPersistableBundle(key, bCopy);
+                }
+            }
+            return copy;
+        }
+
+        /**
+         * Write out a tag with data identifying this job's constraints. If the constraint isn't here
+         * it doesn't apply.
+         */
+        private void writeConstraintsToXml(XmlSerializer out, JobStatus jobStatus) throws IOException {
+            out.startTag(null, XML_TAG_PARAMS_CONSTRAINTS);
+            if (jobStatus.hasConnectivityConstraint()) {
+                final NetworkRequest network = jobStatus.getJob().getRequiredNetwork();
+                out.attribute(null, "net-capabilities", Long.toString(
+                        BitUtils.packBits(network.networkCapabilities.getCapabilities())));
+                out.attribute(null, "net-unwanted-capabilities", Long.toString(
+                        BitUtils.packBits(network.networkCapabilities.getUnwantedCapabilities())));
+
+                out.attribute(null, "net-transport-types", Long.toString(
+                        BitUtils.packBits(network.networkCapabilities.getTransportTypes())));
+            }
+            if (jobStatus.hasIdleConstraint()) {
+                out.attribute(null, "idle", Boolean.toString(true));
+            }
+            if (jobStatus.hasChargingConstraint()) {
+                out.attribute(null, "charging", Boolean.toString(true));
+            }
+            if (jobStatus.hasBatteryNotLowConstraint()) {
+                out.attribute(null, "battery-not-low", Boolean.toString(true));
+            }
+            if (jobStatus.hasStorageNotLowConstraint()) {
+                out.attribute(null, "storage-not-low", Boolean.toString(true));
+            }
+            out.endTag(null, XML_TAG_PARAMS_CONSTRAINTS);
+        }
+
+        private void writeExecutionCriteriaToXml(XmlSerializer out, JobStatus jobStatus)
+                throws IOException {
+            final JobInfo job = jobStatus.getJob();
+            if (jobStatus.getJob().isPeriodic()) {
+                out.startTag(null, XML_TAG_PERIODIC);
+                out.attribute(null, "period", Long.toString(job.getIntervalMillis()));
+                out.attribute(null, "flex", Long.toString(job.getFlexMillis()));
+            } else {
+                out.startTag(null, XML_TAG_ONEOFF);
+            }
+
+            // If we still have the persisted times, we need to record those directly because
+            // we haven't yet been able to calculate the usual elapsed-timebase bounds
+            // correctly due to wall-clock uncertainty.
+            Pair <Long, Long> utcJobTimes = jobStatus.getPersistedUtcTimes();
+            if (DEBUG && utcJobTimes != null) {
+                Slog.i(TAG, "storing original UTC timestamps for " + jobStatus);
+            }
+
+            final long nowRTC = sSystemClock.millis();
+            final long nowElapsed = sElapsedRealtimeClock.millis();
+            if (jobStatus.hasDeadlineConstraint()) {
+                // Wall clock deadline.
+                final long deadlineWallclock = (utcJobTimes == null)
+                        ? nowRTC + (jobStatus.getLatestRunTimeElapsed() - nowElapsed)
+                        : utcJobTimes.second;
+                out.attribute(null, "deadline", Long.toString(deadlineWallclock));
+            }
+            if (jobStatus.hasTimingDelayConstraint()) {
+                final long delayWallclock = (utcJobTimes == null)
+                        ? nowRTC + (jobStatus.getEarliestRunTime() - nowElapsed)
+                        : utcJobTimes.first;
+                out.attribute(null, "delay", Long.toString(delayWallclock));
+            }
+
+            // Only write out back-off policy if it differs from the default.
+            // This also helps the case where the job is idle -> these aren't allowed to specify
+            // back-off.
+            if (jobStatus.getJob().getInitialBackoffMillis() != JobInfo.DEFAULT_INITIAL_BACKOFF_MILLIS
+                    || jobStatus.getJob().getBackoffPolicy() != JobInfo.DEFAULT_BACKOFF_POLICY) {
+                out.attribute(null, "backoff-policy", Integer.toString(job.getBackoffPolicy()));
+                out.attribute(null, "initial-backoff", Long.toString(job.getInitialBackoffMillis()));
+            }
+            if (job.isPeriodic()) {
+                out.endTag(null, XML_TAG_PERIODIC);
+            } else {
+                out.endTag(null, XML_TAG_ONEOFF);
+            }
+        }
+    };
+
+    /**
+     * Translate the supplied RTC times to the elapsed timebase, with clamping appropriate
+     * to interpreting them as a job's delay + deadline times for alarm-setting purposes.
+     * @param rtcTimes a Pair<Long, Long> in which {@code first} is the "delay" earliest
+     *     allowable runtime for the job, and {@code second} is the "deadline" time at which
+     *     the job becomes overdue.
+     */
+    private static Pair<Long, Long> convertRtcBoundsToElapsed(Pair<Long, Long> rtcTimes,
+            long nowElapsed) {
+        final long nowWallclock = sSystemClock.millis();
+        final long earliest = (rtcTimes.first > JobStatus.NO_EARLIEST_RUNTIME)
+                ? nowElapsed + Math.max(rtcTimes.first - nowWallclock, 0)
+                : JobStatus.NO_EARLIEST_RUNTIME;
+        final long latest = (rtcTimes.second < JobStatus.NO_LATEST_RUNTIME)
+                ? nowElapsed + Math.max(rtcTimes.second - nowWallclock, 0)
+                : JobStatus.NO_LATEST_RUNTIME;
+        return Pair.create(earliest, latest);
+    }
+
+    private static boolean isSyncJob(JobStatus status) {
+        return com.android.server.content.SyncJobService.class.getName()
+                .equals(status.getServiceComponent().getClassName());
+    }
+
+    /**
+     * Runnable that reads list of persisted job from xml. This is run once at start up, so doesn't
+     * need to go through {@link JobStore#add(com.android.server.job.controllers.JobStatus)}.
+     */
+    private final class ReadJobMapFromDiskRunnable implements Runnable {
+        private final JobSet jobSet;
+        private final boolean rtcGood;
+
+        /**
+         * @param jobSet Reference to the (empty) set of JobStatus objects that back the JobStore,
+         *               so that after disk read we can populate it directly.
+         */
+        ReadJobMapFromDiskRunnable(JobSet jobSet, boolean rtcIsGood) {
+            this.jobSet = jobSet;
+            this.rtcGood = rtcIsGood;
+        }
+
+        @Override
+        public void run() {
+            int numJobs = 0;
+            int numSystemJobs = 0;
+            int numSyncJobs = 0;
+            try {
+                List<JobStatus> jobs;
+                FileInputStream fis = mJobsFile.openRead();
+                synchronized (mLock) {
+                    jobs = readJobMapImpl(fis, rtcGood);
+                    if (jobs != null) {
+                        long now = sElapsedRealtimeClock.millis();
+                        IActivityManager am = ActivityManager.getService();
+                        for (int i=0; i<jobs.size(); i++) {
+                            JobStatus js = jobs.get(i);
+                            js.prepareLocked(am);
+                            js.enqueueTime = now;
+                            this.jobSet.add(js);
+
+                            numJobs++;
+                            if (js.getUid() == Process.SYSTEM_UID) {
+                                numSystemJobs++;
+                                if (isSyncJob(js)) {
+                                    numSyncJobs++;
+                                }
+                            }
+                        }
+                    }
+                }
+                fis.close();
+            } catch (FileNotFoundException e) {
+                if (DEBUG) {
+                    Slog.d(TAG, "Could not find jobs file, probably there was nothing to load.");
+                }
+            } catch (XmlPullParserException | IOException e) {
+                Slog.wtf(TAG, "Error jobstore xml.", e);
+            } finally {
+                if (mPersistInfo.countAllJobsLoaded < 0) { // Only set them once.
+                    mPersistInfo.countAllJobsLoaded = numJobs;
+                    mPersistInfo.countSystemServerJobsLoaded = numSystemJobs;
+                    mPersistInfo.countSystemSyncManagerJobsLoaded = numSyncJobs;
+                }
+            }
+            Slog.i(TAG, "Read " + numJobs + " jobs");
+        }
+
+        private List<JobStatus> readJobMapImpl(FileInputStream fis, boolean rtcIsGood)
+                throws XmlPullParserException, IOException {
+            XmlPullParser parser = Xml.newPullParser();
+            parser.setInput(fis, StandardCharsets.UTF_8.name());
+
+            int eventType = parser.getEventType();
+            while (eventType != XmlPullParser.START_TAG &&
+                    eventType != XmlPullParser.END_DOCUMENT) {
+                eventType = parser.next();
+                Slog.d(TAG, "Start tag: " + parser.getName());
+            }
+            if (eventType == XmlPullParser.END_DOCUMENT) {
+                if (DEBUG) {
+                    Slog.d(TAG, "No persisted jobs.");
+                }
+                return null;
+            }
+
+            String tagName = parser.getName();
+            if ("job-info".equals(tagName)) {
+                final List<JobStatus> jobs = new ArrayList<JobStatus>();
+                // Read in version info.
+                try {
+                    int version = Integer.parseInt(parser.getAttributeValue(null, "version"));
+                    if (version != JOBS_FILE_VERSION) {
+                        Slog.d(TAG, "Invalid version number, aborting jobs file read.");
+                        return null;
+                    }
+                } catch (NumberFormatException e) {
+                    Slog.e(TAG, "Invalid version number, aborting jobs file read.");
+                    return null;
+                }
+                eventType = parser.next();
+                do {
+                    // Read each <job/>
+                    if (eventType == XmlPullParser.START_TAG) {
+                        tagName = parser.getName();
+                        // Start reading job.
+                        if ("job".equals(tagName)) {
+                            JobStatus persistedJob = restoreJobFromXml(rtcIsGood, parser);
+                            if (persistedJob != null) {
+                                if (DEBUG) {
+                                    Slog.d(TAG, "Read out " + persistedJob);
+                                }
+                                jobs.add(persistedJob);
+                            } else {
+                                Slog.d(TAG, "Error reading job from file.");
+                            }
+                        }
+                    }
+                    eventType = parser.next();
+                } while (eventType != XmlPullParser.END_DOCUMENT);
+                return jobs;
+            }
+            return null;
+        }
+
+        /**
+         * @param parser Xml parser at the beginning of a "<job/>" tag. The next "parser.next()" call
+         *               will take the parser into the body of the job tag.
+         * @return Newly instantiated job holding all the information we just read out of the xml tag.
+         */
+        private JobStatus restoreJobFromXml(boolean rtcIsGood, XmlPullParser parser)
+                throws XmlPullParserException, IOException {
+            JobInfo.Builder jobBuilder;
+            int uid, sourceUserId;
+            long lastSuccessfulRunTime;
+            long lastFailedRunTime;
+            int internalFlags = 0;
+
+            // Read out job identifier attributes and priority.
+            try {
+                jobBuilder = buildBuilderFromXml(parser);
+                jobBuilder.setPersisted(true);
+                uid = Integer.parseInt(parser.getAttributeValue(null, "uid"));
+
+                String val = parser.getAttributeValue(null, "priority");
+                if (val != null) {
+                    jobBuilder.setPriority(Integer.parseInt(val));
+                }
+                val = parser.getAttributeValue(null, "flags");
+                if (val != null) {
+                    jobBuilder.setFlags(Integer.parseInt(val));
+                }
+                val = parser.getAttributeValue(null, "internalFlags");
+                if (val != null) {
+                    internalFlags = Integer.parseInt(val);
+                }
+                val = parser.getAttributeValue(null, "sourceUserId");
+                sourceUserId = val == null ? -1 : Integer.parseInt(val);
+
+                val = parser.getAttributeValue(null, "lastSuccessfulRunTime");
+                lastSuccessfulRunTime = val == null ? 0 : Long.parseLong(val);
+
+                val = parser.getAttributeValue(null, "lastFailedRunTime");
+                lastFailedRunTime = val == null ? 0 : Long.parseLong(val);
+            } catch (NumberFormatException e) {
+                Slog.e(TAG, "Error parsing job's required fields, skipping");
+                return null;
+            }
+
+            String sourcePackageName = parser.getAttributeValue(null, "sourcePackageName");
+            final String sourceTag = parser.getAttributeValue(null, "sourceTag");
+
+            int eventType;
+            // Read out constraints tag.
+            do {
+                eventType = parser.next();
+            } while (eventType == XmlPullParser.TEXT);  // Push through to next START_TAG.
+
+            if (!(eventType == XmlPullParser.START_TAG &&
+                    XML_TAG_PARAMS_CONSTRAINTS.equals(parser.getName()))) {
+                // Expecting a <constraints> start tag.
+                return null;
+            }
+            try {
+                buildConstraintsFromXml(jobBuilder, parser);
+            } catch (NumberFormatException e) {
+                Slog.d(TAG, "Error reading constraints, skipping.");
+                return null;
+            }
+            parser.next(); // Consume </constraints>
+
+            // Read out execution parameters tag.
+            do {
+                eventType = parser.next();
+            } while (eventType == XmlPullParser.TEXT);
+            if (eventType != XmlPullParser.START_TAG) {
+                return null;
+            }
+
+            // Tuple of (earliest runtime, latest runtime) in UTC.
+            final Pair<Long, Long> rtcRuntimes;
+            try {
+                rtcRuntimes = buildRtcExecutionTimesFromXml(parser);
+            } catch (NumberFormatException e) {
+                if (DEBUG) {
+                    Slog.d(TAG, "Error parsing execution time parameters, skipping.");
+                }
+                return null;
+            }
+
+            final long elapsedNow = sElapsedRealtimeClock.millis();
+            Pair<Long, Long> elapsedRuntimes = convertRtcBoundsToElapsed(rtcRuntimes, elapsedNow);
+
+            if (XML_TAG_PERIODIC.equals(parser.getName())) {
+                try {
+                    String val = parser.getAttributeValue(null, "period");
+                    final long periodMillis = Long.parseLong(val);
+                    val = parser.getAttributeValue(null, "flex");
+                    final long flexMillis = (val != null) ? Long.valueOf(val) : periodMillis;
+                    jobBuilder.setPeriodic(periodMillis, flexMillis);
+                    // As a sanity check, cap the recreated run time to be no later than flex+period
+                    // from now. This is the latest the periodic could be pushed out. This could
+                    // happen if the periodic ran early (at flex time before period), and then the
+                    // device rebooted.
+                    if (elapsedRuntimes.second > elapsedNow + periodMillis + flexMillis) {
+                        final long clampedLateRuntimeElapsed = elapsedNow + flexMillis
+                                + periodMillis;
+                        final long clampedEarlyRuntimeElapsed = clampedLateRuntimeElapsed
+                                - flexMillis;
+                        Slog.w(TAG,
+                                String.format("Periodic job for uid='%d' persisted run-time is" +
+                                                " too big [%s, %s]. Clamping to [%s,%s]",
+                                        uid,
+                                        DateUtils.formatElapsedTime(elapsedRuntimes.first / 1000),
+                                        DateUtils.formatElapsedTime(elapsedRuntimes.second / 1000),
+                                        DateUtils.formatElapsedTime(
+                                                clampedEarlyRuntimeElapsed / 1000),
+                                        DateUtils.formatElapsedTime(
+                                                clampedLateRuntimeElapsed / 1000))
+                        );
+                        elapsedRuntimes =
+                                Pair.create(clampedEarlyRuntimeElapsed, clampedLateRuntimeElapsed);
+                    }
+                } catch (NumberFormatException e) {
+                    Slog.d(TAG, "Error reading periodic execution criteria, skipping.");
+                    return null;
+                }
+            } else if (XML_TAG_ONEOFF.equals(parser.getName())) {
+                try {
+                    if (elapsedRuntimes.first != JobStatus.NO_EARLIEST_RUNTIME) {
+                        jobBuilder.setMinimumLatency(elapsedRuntimes.first - elapsedNow);
+                    }
+                    if (elapsedRuntimes.second != JobStatus.NO_LATEST_RUNTIME) {
+                        jobBuilder.setOverrideDeadline(
+                                elapsedRuntimes.second - elapsedNow);
+                    }
+                } catch (NumberFormatException e) {
+                    Slog.d(TAG, "Error reading job execution criteria, skipping.");
+                    return null;
+                }
+            } else {
+                if (DEBUG) {
+                    Slog.d(TAG, "Invalid parameter tag, skipping - " + parser.getName());
+                }
+                // Expecting a parameters start tag.
+                return null;
+            }
+            maybeBuildBackoffPolicyFromXml(jobBuilder, parser);
+
+            parser.nextTag(); // Consume parameters end tag.
+
+            // Read out extras Bundle.
+            do {
+                eventType = parser.next();
+            } while (eventType == XmlPullParser.TEXT);
+            if (!(eventType == XmlPullParser.START_TAG
+                    && XML_TAG_EXTRAS.equals(parser.getName()))) {
+                if (DEBUG) {
+                    Slog.d(TAG, "Error reading extras, skipping.");
+                }
+                return null;
+            }
+
+            PersistableBundle extras = PersistableBundle.restoreFromXml(parser);
+            jobBuilder.setExtras(extras);
+            parser.nextTag(); // Consume </extras>
+
+            final JobInfo builtJob;
+            try {
+                builtJob = jobBuilder.build();
+            } catch (Exception e) {
+                Slog.w(TAG, "Unable to build job from XML, ignoring: "
+                        + jobBuilder.summarize());
+                return null;
+            }
+
+            // Migrate sync jobs forward from earlier, incomplete representation
+            if ("android".equals(sourcePackageName)
+                    && extras != null
+                    && extras.getBoolean("SyncManagerJob", false)) {
+                sourcePackageName = extras.getString("owningPackage", sourcePackageName);
+                if (DEBUG) {
+                    Slog.i(TAG, "Fixing up sync job source package name from 'android' to '"
+                            + sourcePackageName + "'");
+                }
+            }
+
+            // And now we're done
+            JobSchedulerInternal service = LocalServices.getService(JobSchedulerInternal.class);
+            final int appBucket = JobSchedulerService.standbyBucketForPackage(sourcePackageName,
+                    sourceUserId, elapsedNow);
+            long currentHeartbeat = service != null ? service.currentHeartbeat() : 0;
+            JobStatus js = new JobStatus(
+                    jobBuilder.build(), uid, sourcePackageName, sourceUserId,
+                    appBucket, currentHeartbeat, sourceTag,
+                    elapsedRuntimes.first, elapsedRuntimes.second,
+                    lastSuccessfulRunTime, lastFailedRunTime,
+                    (rtcIsGood) ? null : rtcRuntimes, internalFlags);
+            return js;
+        }
+
+        private JobInfo.Builder buildBuilderFromXml(XmlPullParser parser) throws NumberFormatException {
+            // Pull out required fields from <job> attributes.
+            int jobId = Integer.parseInt(parser.getAttributeValue(null, "jobid"));
+            String packageName = parser.getAttributeValue(null, "package");
+            String className = parser.getAttributeValue(null, "class");
+            ComponentName cname = new ComponentName(packageName, className);
+
+            return new JobInfo.Builder(jobId, cname);
+        }
+
+        private void buildConstraintsFromXml(JobInfo.Builder jobBuilder, XmlPullParser parser) {
+            String val;
+
+            final String netCapabilities = parser.getAttributeValue(null, "net-capabilities");
+            final String netUnwantedCapabilities = parser.getAttributeValue(
+                    null, "net-unwanted-capabilities");
+            final String netTransportTypes = parser.getAttributeValue(null, "net-transport-types");
+            if (netCapabilities != null && netTransportTypes != null) {
+                final NetworkRequest request = new NetworkRequest.Builder().build();
+                final long unwantedCapabilities = netUnwantedCapabilities != null
+                        ? Long.parseLong(netUnwantedCapabilities)
+                        : BitUtils.packBits(request.networkCapabilities.getUnwantedCapabilities());
+
+                // We're okay throwing NFE here; caught by caller
+                request.networkCapabilities.setCapabilities(
+                        BitUtils.unpackBits(Long.parseLong(netCapabilities)),
+                        BitUtils.unpackBits(unwantedCapabilities));
+                request.networkCapabilities.setTransportTypes(
+                        BitUtils.unpackBits(Long.parseLong(netTransportTypes)));
+                jobBuilder.setRequiredNetwork(request);
+            } else {
+                // Read legacy values
+                val = parser.getAttributeValue(null, "connectivity");
+                if (val != null) {
+                    jobBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY);
+                }
+                val = parser.getAttributeValue(null, "metered");
+                if (val != null) {
+                    jobBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_METERED);
+                }
+                val = parser.getAttributeValue(null, "unmetered");
+                if (val != null) {
+                    jobBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED);
+                }
+                val = parser.getAttributeValue(null, "not-roaming");
+                if (val != null) {
+                    jobBuilder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_NOT_ROAMING);
+                }
+            }
+
+            val = parser.getAttributeValue(null, "idle");
+            if (val != null) {
+                jobBuilder.setRequiresDeviceIdle(true);
+            }
+            val = parser.getAttributeValue(null, "charging");
+            if (val != null) {
+                jobBuilder.setRequiresCharging(true);
+            }
+            val = parser.getAttributeValue(null, "battery-not-low");
+            if (val != null) {
+                jobBuilder.setRequiresBatteryNotLow(true);
+            }
+            val = parser.getAttributeValue(null, "storage-not-low");
+            if (val != null) {
+                jobBuilder.setRequiresStorageNotLow(true);
+            }
+        }
+
+        /**
+         * Builds the back-off policy out of the params tag. These attributes may not exist, depending
+         * on whether the back-off was set when the job was first scheduled.
+         */
+        private void maybeBuildBackoffPolicyFromXml(JobInfo.Builder jobBuilder, XmlPullParser parser) {
+            String val = parser.getAttributeValue(null, "initial-backoff");
+            if (val != null) {
+                long initialBackoff = Long.parseLong(val);
+                val = parser.getAttributeValue(null, "backoff-policy");
+                int backoffPolicy = Integer.parseInt(val);  // Will throw NFE which we catch higher up.
+                jobBuilder.setBackoffCriteria(initialBackoff, backoffPolicy);
+            }
+        }
+
+        /**
+         * Extract a job's earliest/latest run time data from XML.  These are returned in
+         * unadjusted UTC wall clock time, because we do not yet know whether the system
+         * clock is reliable for purposes of calculating deltas from 'now'.
+         *
+         * @param parser
+         * @return A Pair of timestamps in UTC wall-clock time.  The first is the earliest
+         *     time at which the job is to become runnable, and the second is the deadline at
+         *     which it becomes overdue to execute.
+         * @throws NumberFormatException
+         */
+        private Pair<Long, Long> buildRtcExecutionTimesFromXml(XmlPullParser parser)
+                throws NumberFormatException {
+            String val;
+            // Pull out execution time data.
+            val = parser.getAttributeValue(null, "delay");
+            final long earliestRunTimeRtc = (val != null)
+                    ? Long.parseLong(val)
+                    : JobStatus.NO_EARLIEST_RUNTIME;
+            val = parser.getAttributeValue(null, "deadline");
+            final long latestRunTimeRtc = (val != null)
+                    ? Long.parseLong(val)
+                    : JobStatus.NO_LATEST_RUNTIME;
+            return Pair.create(earliestRunTimeRtc, latestRunTimeRtc);
+        }
+    }
+
+    /** Set of all tracked jobs. */
+    @VisibleForTesting
+    public static final class JobSet {
+        @VisibleForTesting // Key is the getUid() originator of the jobs in each sheaf
+        final SparseArray<ArraySet<JobStatus>> mJobs;
+
+        @VisibleForTesting // Same data but with the key as getSourceUid() of the jobs in each sheaf
+        final SparseArray<ArraySet<JobStatus>> mJobsPerSourceUid;
+
+        public JobSet() {
+            mJobs = new SparseArray<ArraySet<JobStatus>>();
+            mJobsPerSourceUid = new SparseArray<>();
+        }
+
+        public List<JobStatus> getJobsByUid(int uid) {
+            ArrayList<JobStatus> matchingJobs = new ArrayList<JobStatus>();
+            ArraySet<JobStatus> jobs = mJobs.get(uid);
+            if (jobs != null) {
+                matchingJobs.addAll(jobs);
+            }
+            return matchingJobs;
+        }
+
+        // By user, not by uid, so we need to traverse by key and check
+        public List<JobStatus> getJobsByUser(int userId) {
+            final ArrayList<JobStatus> result = new ArrayList<JobStatus>();
+            for (int i = mJobsPerSourceUid.size() - 1; i >= 0; i--) {
+                if (UserHandle.getUserId(mJobsPerSourceUid.keyAt(i)) == userId) {
+                    final ArraySet<JobStatus> jobs = mJobsPerSourceUid.valueAt(i);
+                    if (jobs != null) {
+                        result.addAll(jobs);
+                    }
+                }
+            }
+            return result;
+        }
+
+        public boolean add(JobStatus job) {
+            final int uid = job.getUid();
+            final int sourceUid = job.getSourceUid();
+            ArraySet<JobStatus> jobs = mJobs.get(uid);
+            if (jobs == null) {
+                jobs = new ArraySet<JobStatus>();
+                mJobs.put(uid, jobs);
+            }
+            ArraySet<JobStatus> jobsForSourceUid = mJobsPerSourceUid.get(sourceUid);
+            if (jobsForSourceUid == null) {
+                jobsForSourceUid = new ArraySet<>();
+                mJobsPerSourceUid.put(sourceUid, jobsForSourceUid);
+            }
+            final boolean added = jobs.add(job);
+            final boolean addedInSource = jobsForSourceUid.add(job);
+            if (added != addedInSource) {
+                Slog.wtf(TAG, "mJobs and mJobsPerSourceUid mismatch; caller= " + added
+                        + " source= " + addedInSource);
+            }
+            return added || addedInSource;
+        }
+
+        public boolean remove(JobStatus job) {
+            final int uid = job.getUid();
+            final ArraySet<JobStatus> jobs = mJobs.get(uid);
+            final int sourceUid = job.getSourceUid();
+            final ArraySet<JobStatus> jobsForSourceUid = mJobsPerSourceUid.get(sourceUid);
+            final boolean didRemove = jobs != null && jobs.remove(job);
+            final boolean sourceRemove = jobsForSourceUid != null && jobsForSourceUid.remove(job);
+            if (didRemove != sourceRemove) {
+                Slog.wtf(TAG, "Job presence mismatch; caller=" + didRemove
+                        + " source=" + sourceRemove);
+            }
+            if (didRemove || sourceRemove) {
+                // no more jobs for this uid?  let the now-empty set objects be GC'd.
+                if (jobs != null && jobs.size() == 0) {
+                    mJobs.remove(uid);
+                }
+                if (jobsForSourceUid != null && jobsForSourceUid.size() == 0) {
+                    mJobsPerSourceUid.remove(sourceUid);
+                }
+                return true;
+            }
+            return false;
+        }
+
+        /**
+         * Removes the jobs of all users not specified by the whitelist of user ids.
+         * This will remove jobs scheduled *by* non-existent users as well as jobs scheduled *for*
+         * non-existent users
+         */
+        public void removeJobsOfNonUsers(final int[] whitelist) {
+            final Predicate<JobStatus> noSourceUser =
+                    job -> !ArrayUtils.contains(whitelist, job.getSourceUserId());
+            final Predicate<JobStatus> noCallingUser =
+                    job -> !ArrayUtils.contains(whitelist, job.getUserId());
+            removeAll(noSourceUser.or(noCallingUser));
+        }
+
+        private void removeAll(Predicate<JobStatus> predicate) {
+            for (int jobSetIndex = mJobs.size() - 1; jobSetIndex >= 0; jobSetIndex--) {
+                final ArraySet<JobStatus> jobs = mJobs.valueAt(jobSetIndex);
+                for (int jobIndex = jobs.size() - 1; jobIndex >= 0; jobIndex--) {
+                    if (predicate.test(jobs.valueAt(jobIndex))) {
+                        jobs.removeAt(jobIndex);
+                    }
+                }
+                if (jobs.size() == 0) {
+                    mJobs.removeAt(jobSetIndex);
+                }
+            }
+            for (int jobSetIndex = mJobsPerSourceUid.size() - 1; jobSetIndex >= 0; jobSetIndex--) {
+                final ArraySet<JobStatus> jobs = mJobsPerSourceUid.valueAt(jobSetIndex);
+                for (int jobIndex = jobs.size() - 1; jobIndex >= 0; jobIndex--) {
+                    if (predicate.test(jobs.valueAt(jobIndex))) {
+                        jobs.removeAt(jobIndex);
+                    }
+                }
+                if (jobs.size() == 0) {
+                    mJobsPerSourceUid.removeAt(jobSetIndex);
+                }
+            }
+        }
+
+        public boolean contains(JobStatus job) {
+            final int uid = job.getUid();
+            ArraySet<JobStatus> jobs = mJobs.get(uid);
+            return jobs != null && jobs.contains(job);
+        }
+
+        public JobStatus get(int uid, int jobId) {
+            ArraySet<JobStatus> jobs = mJobs.get(uid);
+            if (jobs != null) {
+                for (int i = jobs.size() - 1; i >= 0; i--) {
+                    JobStatus job = jobs.valueAt(i);
+                    if (job.getJobId() == jobId) {
+                        return job;
+                    }
+                }
+            }
+            return null;
+        }
+
+        // Inefficient; use only for testing
+        public List<JobStatus> getAllJobs() {
+            ArrayList<JobStatus> allJobs = new ArrayList<JobStatus>(size());
+            for (int i = mJobs.size() - 1; i >= 0; i--) {
+                ArraySet<JobStatus> jobs = mJobs.valueAt(i);
+                if (jobs != null) {
+                    // Use a for loop over the ArraySet, so we don't need to make its
+                    // optional collection class iterator implementation or have to go
+                    // through a temporary array from toArray().
+                    for (int j = jobs.size() - 1; j >= 0; j--) {
+                        allJobs.add(jobs.valueAt(j));
+                    }
+                }
+            }
+            return allJobs;
+        }
+
+        public void clear() {
+            mJobs.clear();
+            mJobsPerSourceUid.clear();
+        }
+
+        public int size() {
+            int total = 0;
+            for (int i = mJobs.size() - 1; i >= 0; i--) {
+                total += mJobs.valueAt(i).size();
+            }
+            return total;
+        }
+
+        // We only want to count the jobs that this uid has scheduled on its own
+        // behalf, not those that the app has scheduled on someone else's behalf.
+        public int countJobsForUid(int uid) {
+            int total = 0;
+            ArraySet<JobStatus> jobs = mJobs.get(uid);
+            if (jobs != null) {
+                for (int i = jobs.size() - 1; i >= 0; i--) {
+                    JobStatus job = jobs.valueAt(i);
+                    if (job.getUid() == job.getSourceUid()) {
+                        total++;
+                    }
+                }
+            }
+            return total;
+        }
+
+        public void forEachJob(@Nullable Predicate<JobStatus> filterPredicate,
+                Consumer<JobStatus> functor) {
+            for (int uidIndex = mJobs.size() - 1; uidIndex >= 0; uidIndex--) {
+                ArraySet<JobStatus> jobs = mJobs.valueAt(uidIndex);
+                if (jobs != null) {
+                    for (int i = jobs.size() - 1; i >= 0; i--) {
+                        final JobStatus jobStatus = jobs.valueAt(i);
+                        if ((filterPredicate == null) || filterPredicate.test(jobStatus)) {
+                            functor.accept(jobStatus);
+                        }
+                    }
+                }
+            }
+        }
+
+        public void forEachJob(int callingUid, Consumer<JobStatus> functor) {
+            ArraySet<JobStatus> jobs = mJobs.get(callingUid);
+            if (jobs != null) {
+                for (int i = jobs.size() - 1; i >= 0; i--) {
+                    functor.accept(jobs.valueAt(i));
+                }
+            }
+        }
+
+        public void forEachJobForSourceUid(int sourceUid, Consumer<JobStatus> functor) {
+            final ArraySet<JobStatus> jobs = mJobsPerSourceUid.get(sourceUid);
+            if (jobs != null) {
+                for (int i = jobs.size() - 1; i >= 0; i--) {
+                    functor.accept(jobs.valueAt(i));
+                }
+            }
+        }
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/StateChangedListener.java b/apex/jobscheduler/service/java/com/android/server/job/StateChangedListener.java
new file mode 100644
index 0000000..87bfc27
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/StateChangedListener.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2014 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 com.android.server.job.controllers.JobStatus;
+
+/**
+ * Interface through which a {@link com.android.server.job.controllers.StateController} informs
+ * the {@link com.android.server.job.JobSchedulerService} that there are some tasks potentially
+ * ready to be run.
+ */
+public interface StateChangedListener {
+    /**
+     * Called by the controller to notify the JobManager that it should check on the state of a
+     * task.
+     */
+    public void onControllerStateChanged();
+
+    /**
+     * Called by the controller to notify the JobManager that regardless of the state of the task,
+     * it must be run immediately.
+     * @param jobStatus The state of the task which is to be run immediately. <strong>null
+     *                  indicates to the scheduler that any ready jobs should be flushed.</strong>
+     */
+    public void onRunJobNow(JobStatus jobStatus);
+
+    public void onDeviceIdleStateChanged(boolean deviceIdle);
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/BackgroundJobsController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/BackgroundJobsController.java
new file mode 100644
index 0000000..b698e5b
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/BackgroundJobsController.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2017 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.controllers;
+
+import android.os.SystemClock;
+import android.os.UserHandle;
+import android.util.Log;
+import android.util.Slog;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.internal.util.Preconditions;
+import com.android.server.AppStateTracker;
+import com.android.server.AppStateTracker.Listener;
+import com.android.server.LocalServices;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.JobStore;
+import com.android.server.job.StateControllerProto;
+import com.android.server.job.StateControllerProto.BackgroundJobsController.TrackedJob;
+
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * Tracks the following pieces of JobStatus state:
+ *
+ * - the CONSTRAINT_BACKGROUND_NOT_RESTRICTED general constraint bit, which
+ *    is used to selectively permit battery-saver exempted jobs to run; and
+ *
+ * - the uid-active boolean state expressed by the AppStateTracker.  Jobs in 'active'
+ *    uids are inherently eligible to run jobs regardless of the uid's standby bucket.
+ */
+public final class BackgroundJobsController extends StateController {
+    private static final String TAG = "JobScheduler.Background";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG
+            || Log.isLoggable(TAG, Log.DEBUG);
+
+    // Tri-state about possible "is this uid 'active'?" knowledge
+    static final int UNKNOWN = 0;
+    static final int KNOWN_ACTIVE = 1;
+    static final int KNOWN_INACTIVE = 2;
+
+    private final AppStateTracker mAppStateTracker;
+
+    public BackgroundJobsController(JobSchedulerService service) {
+        super(service);
+
+        mAppStateTracker = Preconditions.checkNotNull(
+                LocalServices.getService(AppStateTracker.class));
+        mAppStateTracker.addListener(mForceAppStandbyListener);
+    }
+
+    @Override
+    public void maybeStartTrackingJobLocked(JobStatus jobStatus, JobStatus lastJob) {
+        updateSingleJobRestrictionLocked(jobStatus, UNKNOWN);
+    }
+
+    @Override
+    public void maybeStopTrackingJobLocked(JobStatus jobStatus, JobStatus incomingJob,
+            boolean forUpdate) {
+    }
+
+    @Override
+    public void dumpControllerStateLocked(final IndentingPrintWriter pw,
+            final Predicate<JobStatus> predicate) {
+        mAppStateTracker.dump(pw);
+        pw.println();
+
+        mService.getJobStore().forEachJob(predicate, (jobStatus) -> {
+            final int uid = jobStatus.getSourceUid();
+            final String sourcePkg = jobStatus.getSourcePackageName();
+            pw.print("#");
+            jobStatus.printUniqueId(pw);
+            pw.print(" from ");
+            UserHandle.formatUid(pw, uid);
+            pw.print(mAppStateTracker.isUidActive(uid) ? " active" : " idle");
+            if (mAppStateTracker.isUidPowerSaveWhitelisted(uid) ||
+                    mAppStateTracker.isUidTempPowerSaveWhitelisted(uid)) {
+                pw.print(", whitelisted");
+            }
+            pw.print(": ");
+            pw.print(sourcePkg);
+
+            pw.print(" [RUN_ANY_IN_BACKGROUND ");
+            pw.print(mAppStateTracker.isRunAnyInBackgroundAppOpsAllowed(uid, sourcePkg)
+                    ? "allowed]" : "disallowed]");
+
+            if ((jobStatus.satisfiedConstraints
+                    & JobStatus.CONSTRAINT_BACKGROUND_NOT_RESTRICTED) != 0) {
+                pw.println(" RUNNABLE");
+            } else {
+                pw.println(" WAITING");
+            }
+        });
+    }
+
+    @Override
+    public void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+            Predicate<JobStatus> predicate) {
+        final long token = proto.start(fieldId);
+        final long mToken = proto.start(StateControllerProto.BACKGROUND);
+
+        mAppStateTracker.dumpProto(proto,
+                StateControllerProto.BackgroundJobsController.FORCE_APP_STANDBY_TRACKER);
+
+        mService.getJobStore().forEachJob(predicate, (jobStatus) -> {
+            final long jsToken =
+                    proto.start(StateControllerProto.BackgroundJobsController.TRACKED_JOBS);
+
+            jobStatus.writeToShortProto(proto,
+                    TrackedJob.INFO);
+            final int sourceUid = jobStatus.getSourceUid();
+            proto.write(TrackedJob.SOURCE_UID, sourceUid);
+            final String sourcePkg = jobStatus.getSourcePackageName();
+            proto.write(TrackedJob.SOURCE_PACKAGE_NAME, sourcePkg);
+
+            proto.write(TrackedJob.IS_IN_FOREGROUND,
+                    mAppStateTracker.isUidActive(sourceUid));
+            proto.write(TrackedJob.IS_WHITELISTED,
+                    mAppStateTracker.isUidPowerSaveWhitelisted(sourceUid) ||
+                    mAppStateTracker.isUidTempPowerSaveWhitelisted(sourceUid));
+
+            proto.write(
+                    TrackedJob.CAN_RUN_ANY_IN_BACKGROUND,
+                    mAppStateTracker.isRunAnyInBackgroundAppOpsAllowed(
+                            sourceUid, sourcePkg));
+
+            proto.write(
+                    TrackedJob.ARE_CONSTRAINTS_SATISFIED,
+                    (jobStatus.satisfiedConstraints &
+                            JobStatus.CONSTRAINT_BACKGROUND_NOT_RESTRICTED) != 0);
+
+            proto.end(jsToken);
+        });
+
+        proto.end(mToken);
+        proto.end(token);
+    }
+
+    private void updateAllJobRestrictionsLocked() {
+        updateJobRestrictionsLocked(/*filterUid=*/ -1, UNKNOWN);
+    }
+
+    private void updateJobRestrictionsForUidLocked(int uid, boolean isActive) {
+        updateJobRestrictionsLocked(uid, (isActive) ? KNOWN_ACTIVE : KNOWN_INACTIVE);
+    }
+
+    private void updateJobRestrictionsLocked(int filterUid, int newActiveState) {
+        final UpdateJobFunctor updateTrackedJobs = new UpdateJobFunctor(newActiveState);
+
+        final long start = DEBUG ? SystemClock.elapsedRealtimeNanos() : 0;
+
+        final JobStore store = mService.getJobStore();
+        if (filterUid > 0) {
+            store.forEachJobForSourceUid(filterUid, updateTrackedJobs);
+        } else {
+            store.forEachJob(updateTrackedJobs);
+        }
+
+        final long time = DEBUG ? (SystemClock.elapsedRealtimeNanos() - start) : 0;
+        if (DEBUG) {
+            Slog.d(TAG, String.format(
+                    "Job status updated: %d/%d checked/total jobs, %d us",
+                    updateTrackedJobs.mCheckedCount,
+                    updateTrackedJobs.mTotalCount,
+                    (time / 1000)
+                    ));
+        }
+
+        if (updateTrackedJobs.mChanged) {
+            mStateChangedListener.onControllerStateChanged();
+        }
+    }
+
+    boolean updateSingleJobRestrictionLocked(JobStatus jobStatus, int activeState) {
+
+        final int uid = jobStatus.getSourceUid();
+        final String packageName = jobStatus.getSourcePackageName();
+
+        final boolean canRun = !mAppStateTracker.areJobsRestricted(uid, packageName,
+                (jobStatus.getInternalFlags() & JobStatus.INTERNAL_FLAG_HAS_FOREGROUND_EXEMPTION)
+                        != 0);
+
+        final boolean isActive;
+        if (activeState == UNKNOWN) {
+            isActive = mAppStateTracker.isUidActive(uid);
+        } else {
+            isActive = (activeState == KNOWN_ACTIVE);
+        }
+        boolean didChange = jobStatus.setBackgroundNotRestrictedConstraintSatisfied(canRun);
+        didChange |= jobStatus.setUidActive(isActive);
+        return didChange;
+    }
+
+    private final class UpdateJobFunctor implements Consumer<JobStatus> {
+        final int activeState;
+        boolean mChanged = false;
+        int mTotalCount = 0;
+        int mCheckedCount = 0;
+
+        public UpdateJobFunctor(int newActiveState) {
+            activeState = newActiveState;
+        }
+
+        @Override
+        public void accept(JobStatus jobStatus) {
+            mTotalCount++;
+            mCheckedCount++;
+            if (updateSingleJobRestrictionLocked(jobStatus, activeState)) {
+                mChanged = true;
+            }
+        }
+    }
+
+    private final Listener mForceAppStandbyListener = new Listener() {
+        @Override
+        public void updateAllJobs() {
+            synchronized (mLock) {
+                updateAllJobRestrictionsLocked();
+            }
+        }
+
+        @Override
+        public void updateJobsForUid(int uid, boolean isActive) {
+            synchronized (mLock) {
+                updateJobRestrictionsForUidLocked(uid, isActive);
+            }
+        }
+
+        @Override
+        public void updateJobsForUidPackage(int uid, String packageName, boolean isActive) {
+            synchronized (mLock) {
+                updateJobRestrictionsForUidLocked(uid, isActive);
+            }
+        }
+    };
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/BatteryController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/BatteryController.java
new file mode 100644
index 0000000..46658ad
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/BatteryController.java
@@ -0,0 +1,282 @@
+/*
+ * Copyright (C) 2014 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.controllers;
+
+import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.BatteryManager;
+import android.os.BatteryManagerInternal;
+import android.os.UserHandle;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.Slog;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.LocalServices;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.StateControllerProto;
+
+import java.util.function.Predicate;
+
+/**
+ * Simple controller that tracks whether the phone is charging or not. The phone is considered to
+ * be charging when it's been plugged in for more than two minutes, and the system has broadcast
+ * ACTION_BATTERY_OK.
+ */
+public final class BatteryController extends StateController {
+    private static final String TAG = "JobScheduler.Battery";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG
+            || Log.isLoggable(TAG, Log.DEBUG);
+
+    private final ArraySet<JobStatus> mTrackedTasks = new ArraySet<>();
+    private ChargingTracker mChargeTracker;
+
+    @VisibleForTesting
+    public ChargingTracker getTracker() {
+        return mChargeTracker;
+    }
+
+    public BatteryController(JobSchedulerService service) {
+        super(service);
+        mChargeTracker = new ChargingTracker();
+        mChargeTracker.startTracking();
+    }
+
+    @Override
+    public void maybeStartTrackingJobLocked(JobStatus taskStatus, JobStatus lastJob) {
+        if (taskStatus.hasPowerConstraint()) {
+            mTrackedTasks.add(taskStatus);
+            taskStatus.setTrackingController(JobStatus.TRACKING_BATTERY);
+            taskStatus.setChargingConstraintSatisfied(mChargeTracker.isOnStablePower());
+            taskStatus.setBatteryNotLowConstraintSatisfied(mChargeTracker.isBatteryNotLow());
+        }
+    }
+
+    @Override
+    public void maybeStopTrackingJobLocked(JobStatus taskStatus, JobStatus incomingJob, boolean forUpdate) {
+        if (taskStatus.clearTrackingController(JobStatus.TRACKING_BATTERY)) {
+            mTrackedTasks.remove(taskStatus);
+        }
+    }
+
+    private void maybeReportNewChargingStateLocked() {
+        final boolean stablePower = mChargeTracker.isOnStablePower();
+        final boolean batteryNotLow = mChargeTracker.isBatteryNotLow();
+        if (DEBUG) {
+            Slog.d(TAG, "maybeReportNewChargingStateLocked: " + stablePower);
+        }
+        boolean reportChange = false;
+        for (int i = mTrackedTasks.size() - 1; i >= 0; i--) {
+            final JobStatus ts = mTrackedTasks.valueAt(i);
+            boolean previous = ts.setChargingConstraintSatisfied(stablePower);
+            if (previous != stablePower) {
+                reportChange = true;
+            }
+            previous = ts.setBatteryNotLowConstraintSatisfied(batteryNotLow);
+            if (previous != batteryNotLow) {
+                reportChange = true;
+            }
+        }
+        if (stablePower || batteryNotLow) {
+            // If one of our conditions has been satisfied, always schedule any newly ready jobs.
+            mStateChangedListener.onRunJobNow(null);
+        } else if (reportChange) {
+            // Otherwise, just let the job scheduler know the state has changed and take care of it
+            // as it thinks is best.
+            mStateChangedListener.onControllerStateChanged();
+        }
+    }
+
+    public final class ChargingTracker extends BroadcastReceiver {
+        /**
+         * Track whether we're "charging", where charging means that we're ready to commit to
+         * doing work.
+         */
+        private boolean mCharging;
+        /** Keep track of whether the battery is charged enough that we want to do work. */
+        private boolean mBatteryHealthy;
+        /** Sequence number of last broadcast. */
+        private int mLastBatterySeq = -1;
+
+        private BroadcastReceiver mMonitor;
+
+        public ChargingTracker() {
+        }
+
+        public void startTracking() {
+            IntentFilter filter = new IntentFilter();
+
+            // Battery health.
+            filter.addAction(Intent.ACTION_BATTERY_LOW);
+            filter.addAction(Intent.ACTION_BATTERY_OKAY);
+            // Charging/not charging.
+            filter.addAction(BatteryManager.ACTION_CHARGING);
+            filter.addAction(BatteryManager.ACTION_DISCHARGING);
+            mContext.registerReceiver(this, filter);
+
+            // Initialise tracker state.
+            BatteryManagerInternal batteryManagerInternal =
+                    LocalServices.getService(BatteryManagerInternal.class);
+            mBatteryHealthy = !batteryManagerInternal.getBatteryLevelLow();
+            mCharging = batteryManagerInternal.isPowered(BatteryManager.BATTERY_PLUGGED_ANY);
+        }
+
+        public void setMonitorBatteryLocked(boolean enabled) {
+            if (enabled) {
+                if (mMonitor == null) {
+                    mMonitor = new BroadcastReceiver() {
+                        @Override public void onReceive(Context context, Intent intent) {
+                            ChargingTracker.this.onReceive(context, intent);
+                        }
+                    };
+                    IntentFilter filter = new IntentFilter();
+                    filter.addAction(Intent.ACTION_BATTERY_CHANGED);
+                    mContext.registerReceiver(mMonitor, filter);
+                }
+            } else {
+                if (mMonitor != null) {
+                    mContext.unregisterReceiver(mMonitor);
+                    mMonitor = null;
+                }
+            }
+        }
+
+        public boolean isOnStablePower() {
+            return mCharging && mBatteryHealthy;
+        }
+
+        public boolean isBatteryNotLow() {
+            return mBatteryHealthy;
+        }
+
+        public boolean isMonitoring() {
+            return mMonitor != null;
+        }
+
+        public int getSeq() {
+            return mLastBatterySeq;
+        }
+
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            onReceiveInternal(intent);
+        }
+
+        @VisibleForTesting
+        public void onReceiveInternal(Intent intent) {
+            synchronized (mLock) {
+                final String action = intent.getAction();
+                if (Intent.ACTION_BATTERY_LOW.equals(action)) {
+                    if (DEBUG) {
+                        Slog.d(TAG, "Battery life too low to do work. @ "
+                                + sElapsedRealtimeClock.millis());
+                    }
+                    // If we get this action, the battery is discharging => it isn't plugged in so
+                    // there's no work to cancel. We track this variable for the case where it is
+                    // charging, but hasn't been for long enough to be healthy.
+                    mBatteryHealthy = false;
+                    maybeReportNewChargingStateLocked();
+                } else if (Intent.ACTION_BATTERY_OKAY.equals(action)) {
+                    if (DEBUG) {
+                        Slog.d(TAG, "Battery life healthy enough to do work. @ "
+                                + sElapsedRealtimeClock.millis());
+                    }
+                    mBatteryHealthy = true;
+                    maybeReportNewChargingStateLocked();
+                } else if (BatteryManager.ACTION_CHARGING.equals(action)) {
+                    if (DEBUG) {
+                        Slog.d(TAG, "Received charging intent, fired @ "
+                                + sElapsedRealtimeClock.millis());
+                    }
+                    mCharging = true;
+                    maybeReportNewChargingStateLocked();
+                } else if (BatteryManager.ACTION_DISCHARGING.equals(action)) {
+                    if (DEBUG) {
+                        Slog.d(TAG, "Disconnected from power.");
+                    }
+                    mCharging = false;
+                    maybeReportNewChargingStateLocked();
+                }
+                mLastBatterySeq = intent.getIntExtra(BatteryManager.EXTRA_SEQUENCE,
+                        mLastBatterySeq);
+            }
+        }
+    }
+
+    @Override
+    public void dumpControllerStateLocked(IndentingPrintWriter pw,
+            Predicate<JobStatus> predicate) {
+        pw.println("Stable power: " + mChargeTracker.isOnStablePower());
+        pw.println("Not low: " + mChargeTracker.isBatteryNotLow());
+
+        if (mChargeTracker.isMonitoring()) {
+            pw.print("MONITORING: seq=");
+            pw.println(mChargeTracker.getSeq());
+        }
+        pw.println();
+
+        for (int i = 0; i < mTrackedTasks.size(); i++) {
+            final JobStatus js = mTrackedTasks.valueAt(i);
+            if (!predicate.test(js)) {
+                continue;
+            }
+            pw.print("#");
+            js.printUniqueId(pw);
+            pw.print(" from ");
+            UserHandle.formatUid(pw, js.getSourceUid());
+            pw.println();
+        }
+    }
+
+    @Override
+    public void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+            Predicate<JobStatus> predicate) {
+        final long token = proto.start(fieldId);
+        final long mToken = proto.start(StateControllerProto.BATTERY);
+
+        proto.write(StateControllerProto.BatteryController.IS_ON_STABLE_POWER,
+                mChargeTracker.isOnStablePower());
+        proto.write(StateControllerProto.BatteryController.IS_BATTERY_NOT_LOW,
+                mChargeTracker.isBatteryNotLow());
+
+        proto.write(StateControllerProto.BatteryController.IS_MONITORING,
+                mChargeTracker.isMonitoring());
+        proto.write(StateControllerProto.BatteryController.LAST_BROADCAST_SEQUENCE_NUMBER,
+                mChargeTracker.getSeq());
+
+        for (int i = 0; i < mTrackedTasks.size(); i++) {
+            final JobStatus js = mTrackedTasks.valueAt(i);
+            if (!predicate.test(js)) {
+                continue;
+            }
+            final long jsToken = proto.start(StateControllerProto.BatteryController.TRACKED_JOBS);
+            js.writeToShortProto(proto, StateControllerProto.BatteryController.TrackedJob.INFO);
+            proto.write(StateControllerProto.BatteryController.TrackedJob.SOURCE_UID,
+                    js.getSourceUid());
+            proto.end(jsToken);
+        }
+
+        proto.end(mToken);
+        proto.end(token);
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/ConnectivityController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/ConnectivityController.java
new file mode 100644
index 0000000..f8cf6ae
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/ConnectivityController.java
@@ -0,0 +1,694 @@
+/*
+ * Copyright (C) 2014 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.controllers;
+
+import static android.net.NetworkCapabilities.LINK_BANDWIDTH_UNSPECIFIED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED;
+import static android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED;
+
+import android.app.job.JobInfo;
+import android.net.ConnectivityManager;
+import android.net.ConnectivityManager.NetworkCallback;
+import android.net.INetworkPolicyListener;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.NetworkInfo;
+import android.net.NetworkPolicyManager;
+import android.net.NetworkRequest;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.UserHandle;
+import android.text.format.DateUtils;
+import android.util.ArraySet;
+import android.util.DataUnit;
+import android.util.Log;
+import android.util.Slog;
+import android.util.SparseArray;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.LocalServices;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.JobSchedulerService.Constants;
+import com.android.server.job.JobServiceContext;
+import com.android.server.job.StateControllerProto;
+import com.android.server.net.NetworkPolicyManagerInternal;
+
+import java.util.Objects;
+import java.util.function.Predicate;
+
+/**
+ * Handles changes in connectivity.
+ * <p>
+ * Each app can have a different default networks or different connectivity
+ * status due to user-requested network policies, so we need to check
+ * constraints on a per-UID basis.
+ *
+ * Test: atest com.android.server.job.controllers.ConnectivityControllerTest
+ */
+public final class ConnectivityController extends StateController implements
+        ConnectivityManager.OnNetworkActiveListener {
+    private static final String TAG = "JobScheduler.Connectivity";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG
+            || Log.isLoggable(TAG, Log.DEBUG);
+
+    private final ConnectivityManager mConnManager;
+    private final NetworkPolicyManager mNetPolicyManager;
+    private final NetworkPolicyManagerInternal mNetPolicyManagerInternal;
+
+    /** List of tracked jobs keyed by source UID. */
+    @GuardedBy("mLock")
+    private final SparseArray<ArraySet<JobStatus>> mTrackedJobs = new SparseArray<>();
+
+    /**
+     * Keep track of all the UID's jobs that the controller has requested that NetworkPolicyManager
+     * grant an exception to in the app standby chain.
+     */
+    @GuardedBy("mLock")
+    private final SparseArray<ArraySet<JobStatus>> mRequestedWhitelistJobs = new SparseArray<>();
+
+    /** List of currently available networks. */
+    @GuardedBy("mLock")
+    private final ArraySet<Network> mAvailableNetworks = new ArraySet<>();
+
+    private boolean mUseQuotaLimit;
+
+    private static final int MSG_DATA_SAVER_TOGGLED = 0;
+    private static final int MSG_UID_RULES_CHANGES = 1;
+    private static final int MSG_REEVALUATE_JOBS = 2;
+
+    private final Handler mHandler;
+
+    public ConnectivityController(JobSchedulerService service) {
+        super(service);
+        mHandler = new CcHandler(mContext.getMainLooper());
+
+        mConnManager = mContext.getSystemService(ConnectivityManager.class);
+        mNetPolicyManager = mContext.getSystemService(NetworkPolicyManager.class);
+        mNetPolicyManagerInternal = LocalServices.getService(NetworkPolicyManagerInternal.class);
+
+        // We're interested in all network changes; internally we match these
+        // network changes against the active network for each UID with jobs.
+        final NetworkRequest request = new NetworkRequest.Builder().clearCapabilities().build();
+        mConnManager.registerNetworkCallback(request, mNetworkCallback);
+
+        mNetPolicyManager.registerListener(mNetPolicyListener);
+
+        mUseQuotaLimit = !mConstants.USE_HEARTBEATS;
+    }
+
+    @GuardedBy("mLock")
+    @Override
+    public void maybeStartTrackingJobLocked(JobStatus jobStatus, JobStatus lastJob) {
+        if (jobStatus.hasConnectivityConstraint()) {
+            updateConstraintsSatisfied(jobStatus);
+            ArraySet<JobStatus> jobs = mTrackedJobs.get(jobStatus.getSourceUid());
+            if (jobs == null) {
+                jobs = new ArraySet<>();
+                mTrackedJobs.put(jobStatus.getSourceUid(), jobs);
+            }
+            jobs.add(jobStatus);
+            jobStatus.setTrackingController(JobStatus.TRACKING_CONNECTIVITY);
+        }
+    }
+
+    @GuardedBy("mLock")
+    @Override
+    public void maybeStopTrackingJobLocked(JobStatus jobStatus, JobStatus incomingJob,
+            boolean forUpdate) {
+        if (jobStatus.clearTrackingController(JobStatus.TRACKING_CONNECTIVITY)) {
+            ArraySet<JobStatus> jobs = mTrackedJobs.get(jobStatus.getSourceUid());
+            if (jobs != null) {
+                jobs.remove(jobStatus);
+            }
+            maybeRevokeStandbyExceptionLocked(jobStatus);
+        }
+    }
+
+    @GuardedBy("mLock")
+    @Override
+    public void onConstantsUpdatedLocked() {
+        if (mConstants.USE_HEARTBEATS) {
+            // App idle exceptions are only requested for the rolling quota system.
+            if (DEBUG) Slog.i(TAG, "Revoking all standby exceptions");
+            for (int i = 0; i < mRequestedWhitelistJobs.size(); ++i) {
+                int uid = mRequestedWhitelistJobs.keyAt(i);
+                mNetPolicyManagerInternal.setAppIdleWhitelist(uid, false);
+            }
+            mRequestedWhitelistJobs.clear();
+        }
+        if (mUseQuotaLimit == mConstants.USE_HEARTBEATS) {
+            mUseQuotaLimit = !mConstants.USE_HEARTBEATS;
+            mHandler.obtainMessage(MSG_REEVALUATE_JOBS).sendToTarget();
+        }
+    }
+
+    /**
+     * Returns true if the job's requested network is available. This DOES NOT necesarilly mean
+     * that the UID has been granted access to the network.
+     */
+    public boolean isNetworkAvailable(JobStatus job) {
+        synchronized (mLock) {
+            for (int i = 0; i < mAvailableNetworks.size(); ++i) {
+                final Network network = mAvailableNetworks.valueAt(i);
+                final NetworkCapabilities capabilities = mConnManager.getNetworkCapabilities(
+                        network);
+                final boolean satisfied = isSatisfied(job, network, capabilities, mConstants);
+                if (DEBUG) {
+                    Slog.v(TAG, "isNetworkAvailable(" + job + ") with network " + network
+                            + " and capabilities " + capabilities + ". Satisfied=" + satisfied);
+                }
+                if (satisfied) {
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
+
+    /**
+     * Request that NetworkPolicyManager grant an exception to the uid from its standby policy
+     * chain.
+     */
+    @VisibleForTesting
+    @GuardedBy("mLock")
+    void requestStandbyExceptionLocked(JobStatus job) {
+        final int uid = job.getSourceUid();
+        // Need to call this before adding the job.
+        final boolean isExceptionRequested = isStandbyExceptionRequestedLocked(uid);
+        ArraySet<JobStatus> jobs = mRequestedWhitelistJobs.get(uid);
+        if (jobs == null) {
+            jobs = new ArraySet<JobStatus>();
+            mRequestedWhitelistJobs.put(uid, jobs);
+        }
+        if (!jobs.add(job) || isExceptionRequested) {
+            if (DEBUG) {
+                Slog.i(TAG, "requestStandbyExceptionLocked found exception already requested.");
+            }
+            return;
+        }
+        if (DEBUG) Slog.i(TAG, "Requesting standby exception for UID: " + uid);
+        mNetPolicyManagerInternal.setAppIdleWhitelist(uid, true);
+    }
+
+    /** Returns whether a standby exception has been requested for the UID. */
+    @VisibleForTesting
+    @GuardedBy("mLock")
+    boolean isStandbyExceptionRequestedLocked(final int uid) {
+        ArraySet jobs = mRequestedWhitelistJobs.get(uid);
+        return jobs != null && jobs.size() > 0;
+    }
+
+    @VisibleForTesting
+    @GuardedBy("mLock")
+    boolean wouldBeReadyWithConnectivityLocked(JobStatus jobStatus) {
+        final boolean networkAvailable = isNetworkAvailable(jobStatus);
+        if (DEBUG) {
+            Slog.v(TAG, "wouldBeReadyWithConnectivityLocked: " + jobStatus.toShortString()
+                    + " networkAvailable=" + networkAvailable);
+        }
+        // If the network isn't available, then requesting an exception won't help.
+
+        return networkAvailable && wouldBeReadyWithConstraintLocked(jobStatus,
+                JobStatus.CONSTRAINT_CONNECTIVITY);
+    }
+
+    /**
+     * Tell NetworkPolicyManager not to block a UID's network connection if that's the only
+     * thing stopping a job from running.
+     */
+    @GuardedBy("mLock")
+    @Override
+    public void evaluateStateLocked(JobStatus jobStatus) {
+        if (mConstants.USE_HEARTBEATS) {
+            // This should only be used for the rolling quota system.
+            return;
+        }
+
+        if (!jobStatus.hasConnectivityConstraint()) {
+            return;
+        }
+
+        // Always check the full job readiness stat in case the component has been disabled.
+        if (wouldBeReadyWithConnectivityLocked(jobStatus)) {
+            if (DEBUG) {
+                Slog.i(TAG, "evaluateStateLocked finds job " + jobStatus + " would be ready.");
+            }
+            requestStandbyExceptionLocked(jobStatus);
+        } else {
+            if (DEBUG) {
+                Slog.i(TAG, "evaluateStateLocked finds job " + jobStatus + " would not be ready.");
+            }
+            maybeRevokeStandbyExceptionLocked(jobStatus);
+        }
+    }
+
+    @GuardedBy("mLock")
+    @Override
+    public void reevaluateStateLocked(final int uid) {
+        if (mConstants.USE_HEARTBEATS) {
+            return;
+        }
+        // Check if we still need a connectivity exception in case the JobService was disabled.
+        ArraySet<JobStatus> jobs = mTrackedJobs.get(uid);
+        if (jobs == null) {
+            return;
+        }
+        for (int i = jobs.size() - 1; i >= 0; i--) {
+            evaluateStateLocked(jobs.valueAt(i));
+        }
+    }
+
+    /** Cancel the requested standby exception if none of the jobs would be ready to run anyway. */
+    @VisibleForTesting
+    @GuardedBy("mLock")
+    void maybeRevokeStandbyExceptionLocked(final JobStatus job) {
+        final int uid = job.getSourceUid();
+        if (!isStandbyExceptionRequestedLocked(uid)) {
+            return;
+        }
+        ArraySet<JobStatus> jobs = mRequestedWhitelistJobs.get(uid);
+        if (jobs == null) {
+            Slog.wtf(TAG,
+                    "maybeRevokeStandbyExceptionLocked found null jobs array even though a "
+                            + "standby exception has been requested.");
+            return;
+        }
+        if (!jobs.remove(job) || jobs.size() > 0) {
+            if (DEBUG) {
+                Slog.i(TAG,
+                        "maybeRevokeStandbyExceptionLocked not revoking because there are still "
+                                + jobs.size() + " jobs left.");
+            }
+            return;
+        }
+        // No more jobs that need an exception.
+        revokeStandbyExceptionLocked(uid);
+    }
+
+    /**
+     * Tell NetworkPolicyManager to revoke any exception it granted from its standby policy chain
+     * for the uid.
+     */
+    @GuardedBy("mLock")
+    private void revokeStandbyExceptionLocked(final int uid) {
+        if (DEBUG) Slog.i(TAG, "Revoking standby exception for UID: " + uid);
+        mNetPolicyManagerInternal.setAppIdleWhitelist(uid, false);
+        mRequestedWhitelistJobs.remove(uid);
+    }
+
+    @GuardedBy("mLock")
+    @Override
+    public void onAppRemovedLocked(String pkgName, int uid) {
+        mTrackedJobs.delete(uid);
+    }
+
+    /**
+     * Test to see if running the given job on the given network is insane.
+     * <p>
+     * For example, if a job is trying to send 10MB over a 128Kbps EDGE
+     * connection, it would take 10.4 minutes, and has no chance of succeeding
+     * before the job times out, so we'd be insane to try running it.
+     */
+    private boolean isInsane(JobStatus jobStatus, Network network,
+            NetworkCapabilities capabilities, Constants constants) {
+        final long maxJobExecutionTimeMs = mUseQuotaLimit
+                ? mService.getMaxJobExecutionTimeMs(jobStatus)
+                : JobServiceContext.EXECUTING_TIMESLICE_MILLIS;
+
+        final long downloadBytes = jobStatus.getEstimatedNetworkDownloadBytes();
+        if (downloadBytes != JobInfo.NETWORK_BYTES_UNKNOWN) {
+            final long bandwidth = capabilities.getLinkDownstreamBandwidthKbps();
+            // If we don't know the bandwidth, all we can do is hope the job finishes in time.
+            if (bandwidth != LINK_BANDWIDTH_UNSPECIFIED) {
+                // Divide by 8 to convert bits to bytes.
+                final long estimatedMillis = ((downloadBytes * DateUtils.SECOND_IN_MILLIS)
+                        / (DataUnit.KIBIBYTES.toBytes(bandwidth) / 8));
+                if (estimatedMillis > maxJobExecutionTimeMs) {
+                    // If we'd never finish before the timeout, we'd be insane!
+                    Slog.w(TAG, "Estimated " + downloadBytes + " download bytes over " + bandwidth
+                            + " kbps network would take " + estimatedMillis + "ms and job has "
+                            + maxJobExecutionTimeMs + "ms to run; that's insane!");
+                    return true;
+                }
+            }
+        }
+
+        final long uploadBytes = jobStatus.getEstimatedNetworkUploadBytes();
+        if (uploadBytes != JobInfo.NETWORK_BYTES_UNKNOWN) {
+            final long bandwidth = capabilities.getLinkUpstreamBandwidthKbps();
+            // If we don't know the bandwidth, all we can do is hope the job finishes in time.
+            if (bandwidth != LINK_BANDWIDTH_UNSPECIFIED) {
+                // Divide by 8 to convert bits to bytes.
+                final long estimatedMillis = ((uploadBytes * DateUtils.SECOND_IN_MILLIS)
+                        / (DataUnit.KIBIBYTES.toBytes(bandwidth) / 8));
+                if (estimatedMillis > maxJobExecutionTimeMs) {
+                    // If we'd never finish before the timeout, we'd be insane!
+                    Slog.w(TAG, "Estimated " + uploadBytes + " upload bytes over " + bandwidth
+                            + " kbps network would take " + estimatedMillis + "ms and job has "
+                            + maxJobExecutionTimeMs + "ms to run; that's insane!");
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    private static boolean isCongestionDelayed(JobStatus jobStatus, Network network,
+            NetworkCapabilities capabilities, Constants constants) {
+        // If network is congested, and job is less than 50% through the
+        // developer-requested window, then we're okay delaying the job.
+        if (!capabilities.hasCapability(NET_CAPABILITY_NOT_CONGESTED)) {
+            return jobStatus.getFractionRunTime() < constants.CONN_CONGESTION_DELAY_FRAC;
+        } else {
+            return false;
+        }
+    }
+
+    private static boolean isStrictSatisfied(JobStatus jobStatus, Network network,
+            NetworkCapabilities capabilities, Constants constants) {
+        return jobStatus.getJob().getRequiredNetwork().networkCapabilities
+                .satisfiedByNetworkCapabilities(capabilities);
+    }
+
+    private static boolean isRelaxedSatisfied(JobStatus jobStatus, Network network,
+            NetworkCapabilities capabilities, Constants constants) {
+        // Only consider doing this for prefetching jobs
+        if (!jobStatus.getJob().isPrefetch()) {
+            return false;
+        }
+
+        // See if we match after relaxing any unmetered request
+        final NetworkCapabilities relaxed = new NetworkCapabilities(
+                jobStatus.getJob().getRequiredNetwork().networkCapabilities)
+                        .removeCapability(NET_CAPABILITY_NOT_METERED);
+        if (relaxed.satisfiedByNetworkCapabilities(capabilities)) {
+            // TODO: treat this as "maybe" response; need to check quotas
+            return jobStatus.getFractionRunTime() > constants.CONN_PREFETCH_RELAX_FRAC;
+        } else {
+            return false;
+        }
+    }
+
+    @VisibleForTesting
+    boolean isSatisfied(JobStatus jobStatus, Network network,
+            NetworkCapabilities capabilities, Constants constants) {
+        // Zeroth, we gotta have a network to think about being satisfied
+        if (network == null || capabilities == null) return false;
+
+        // First, are we insane?
+        if (isInsane(jobStatus, network, capabilities, constants)) return false;
+
+        // Second, is the network congested?
+        if (isCongestionDelayed(jobStatus, network, capabilities, constants)) return false;
+
+        // Third, is the network a strict match?
+        if (isStrictSatisfied(jobStatus, network, capabilities, constants)) return true;
+
+        // Third, is the network a relaxed match?
+        if (isRelaxedSatisfied(jobStatus, network, capabilities, constants)) return true;
+
+        return false;
+    }
+
+    private boolean updateConstraintsSatisfied(JobStatus jobStatus) {
+        final Network network = mConnManager.getActiveNetworkForUid(jobStatus.getSourceUid());
+        final NetworkCapabilities capabilities = mConnManager.getNetworkCapabilities(network);
+        return updateConstraintsSatisfied(jobStatus, network, capabilities);
+    }
+
+    private boolean updateConstraintsSatisfied(JobStatus jobStatus, Network network,
+            NetworkCapabilities capabilities) {
+        // TODO: consider matching against non-active networks
+
+        final boolean ignoreBlocked = (jobStatus.getFlags() & JobInfo.FLAG_WILL_BE_FOREGROUND) != 0;
+        final NetworkInfo info = mConnManager.getNetworkInfoForUid(network,
+                jobStatus.getSourceUid(), ignoreBlocked);
+
+        final boolean connected = (info != null) && info.isConnected();
+        final boolean satisfied = isSatisfied(jobStatus, network, capabilities, mConstants);
+
+        final boolean changed = jobStatus
+                .setConnectivityConstraintSatisfied(connected && satisfied);
+
+        // Pass along the evaluated network for job to use; prevents race
+        // conditions as default routes change over time, and opens the door to
+        // using non-default routes.
+        jobStatus.network = network;
+
+        if (DEBUG) {
+            Slog.i(TAG, "Connectivity " + (changed ? "CHANGED" : "unchanged")
+                    + " for " + jobStatus + ": connected=" + connected
+                    + " satisfied=" + satisfied);
+        }
+        return changed;
+    }
+
+    /**
+     * Update any jobs tracked by this controller that match given filters.
+     *
+     * @param filterUid     only update jobs belonging to this UID, or {@code -1} to
+     *                      update all tracked jobs.
+     * @param filterNetwork only update jobs that would use this
+     *                      {@link Network}, or {@code null} to update all tracked jobs.
+     */
+    private void updateTrackedJobs(int filterUid, Network filterNetwork) {
+        synchronized (mLock) {
+            // Since this is a really hot codepath, temporarily cache any
+            // answers that we get from ConnectivityManager.
+            final SparseArray<NetworkCapabilities> networkToCapabilities = new SparseArray<>();
+
+            boolean changed = false;
+            if (filterUid == -1) {
+                for (int i = mTrackedJobs.size() - 1; i >= 0; i--) {
+                    changed |= updateTrackedJobsLocked(mTrackedJobs.valueAt(i),
+                            filterNetwork, networkToCapabilities);
+                }
+            } else {
+                changed = updateTrackedJobsLocked(mTrackedJobs.get(filterUid),
+                        filterNetwork, networkToCapabilities);
+            }
+            if (changed) {
+                mStateChangedListener.onControllerStateChanged();
+            }
+        }
+    }
+
+    private boolean updateTrackedJobsLocked(ArraySet<JobStatus> jobs, Network filterNetwork,
+            SparseArray<NetworkCapabilities> networkToCapabilities) {
+        if (jobs == null || jobs.size() == 0) {
+            return false;
+        }
+
+        final Network network = mConnManager.getActiveNetworkForUid(jobs.valueAt(0).getSourceUid());
+        final int netId = network != null ? network.netId : -1;
+        NetworkCapabilities capabilities = networkToCapabilities.get(netId);
+        if (capabilities == null) {
+            capabilities = mConnManager.getNetworkCapabilities(network);
+            networkToCapabilities.put(netId, capabilities);
+        }
+        final boolean networkMatch = (filterNetwork == null
+                || Objects.equals(filterNetwork, network));
+
+        boolean changed = false;
+        for (int i = jobs.size() - 1; i >= 0; i--) {
+            final JobStatus js = jobs.valueAt(i);
+
+            // Update either when we have a network match, or when the
+            // job hasn't yet been evaluated against the currently
+            // active network; typically when we just lost a network.
+            if (networkMatch || !Objects.equals(js.network, network)) {
+                changed |= updateConstraintsSatisfied(js, network, capabilities);
+            }
+        }
+        return changed;
+    }
+
+    /**
+     * We know the network has just come up. We want to run any jobs that are ready.
+     */
+    @Override
+    public void onNetworkActive() {
+        synchronized (mLock) {
+            for (int i = mTrackedJobs.size()-1; i >= 0; i--) {
+                final ArraySet<JobStatus> jobs = mTrackedJobs.valueAt(i);
+                for (int j = jobs.size() - 1; j >= 0; j--) {
+                    final JobStatus js = jobs.valueAt(j);
+                    if (js.isReady()) {
+                        if (DEBUG) {
+                            Slog.d(TAG, "Running " + js + " due to network activity.");
+                        }
+                        mStateChangedListener.onRunJobNow(js);
+                    }
+                }
+            }
+        }
+    }
+
+    private final NetworkCallback mNetworkCallback = new NetworkCallback() {
+        @Override
+        public void onAvailable(Network network) {
+            if (DEBUG) Slog.v(TAG, "onAvailable: " + network);
+            synchronized (mLock) {
+                mAvailableNetworks.add(network);
+            }
+        }
+
+        @Override
+        public void onCapabilitiesChanged(Network network, NetworkCapabilities capabilities) {
+            if (DEBUG) {
+                Slog.v(TAG, "onCapabilitiesChanged: " + network);
+            }
+            updateTrackedJobs(-1, network);
+        }
+
+        @Override
+        public void onLost(Network network) {
+            if (DEBUG) {
+                Slog.v(TAG, "onLost: " + network);
+            }
+            synchronized (mLock) {
+                mAvailableNetworks.remove(network);
+            }
+            updateTrackedJobs(-1, network);
+        }
+    };
+
+    private final INetworkPolicyListener mNetPolicyListener = new NetworkPolicyManager.Listener() {
+        @Override
+        public void onRestrictBackgroundChanged(boolean restrictBackground) {
+            if (DEBUG) {
+                Slog.v(TAG, "onRestrictBackgroundChanged: " + restrictBackground);
+            }
+            mHandler.obtainMessage(MSG_DATA_SAVER_TOGGLED).sendToTarget();
+        }
+
+        @Override
+        public void onUidRulesChanged(int uid, int uidRules) {
+            if (DEBUG) {
+                Slog.v(TAG, "onUidRulesChanged: " + uid);
+            }
+            mHandler.obtainMessage(MSG_UID_RULES_CHANGES, uid, 0).sendToTarget();
+        }
+    };
+
+    private class CcHandler extends Handler {
+        CcHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            synchronized (mLock) {
+                switch (msg.what) {
+                    case MSG_DATA_SAVER_TOGGLED:
+                        updateTrackedJobs(-1, null);
+                        break;
+                    case MSG_UID_RULES_CHANGES:
+                        updateTrackedJobs(msg.arg1, null);
+                        break;
+                    case MSG_REEVALUATE_JOBS:
+                        updateTrackedJobs(-1, null);
+                        break;
+                }
+            }
+        }
+    };
+
+    @GuardedBy("mLock")
+    @Override
+    public void dumpControllerStateLocked(IndentingPrintWriter pw,
+            Predicate<JobStatus> predicate) {
+        pw.print("mUseQuotaLimit="); pw.println(mUseQuotaLimit);
+
+        if (mRequestedWhitelistJobs.size() > 0) {
+            pw.print("Requested standby exceptions:");
+            for (int i = 0; i < mRequestedWhitelistJobs.size(); i++) {
+                pw.print(" ");
+                pw.print(mRequestedWhitelistJobs.keyAt(i));
+                pw.print(" (");
+                pw.print(mRequestedWhitelistJobs.valueAt(i).size());
+                pw.print(" jobs)");
+            }
+            pw.println();
+        }
+        if (mAvailableNetworks.size() > 0) {
+            pw.println("Available networks:");
+            pw.increaseIndent();
+            for (int i = 0; i < mAvailableNetworks.size(); i++) {
+                pw.println(mAvailableNetworks.valueAt(i));
+            }
+            pw.decreaseIndent();
+        } else {
+            pw.println("No available networks");
+        }
+        for (int i = 0; i < mTrackedJobs.size(); i++) {
+            final ArraySet<JobStatus> jobs = mTrackedJobs.valueAt(i);
+            for (int j = 0; j < jobs.size(); j++) {
+                final JobStatus js = jobs.valueAt(j);
+                if (!predicate.test(js)) {
+                    continue;
+                }
+                pw.print("#");
+                js.printUniqueId(pw);
+                pw.print(" from ");
+                UserHandle.formatUid(pw, js.getSourceUid());
+                pw.print(": ");
+                pw.print(js.getJob().getRequiredNetwork());
+                pw.println();
+            }
+        }
+    }
+
+    @GuardedBy("mLock")
+    @Override
+    public void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+            Predicate<JobStatus> predicate) {
+        final long token = proto.start(fieldId);
+        final long mToken = proto.start(StateControllerProto.CONNECTIVITY);
+
+        for (int i = 0; i < mTrackedJobs.size(); i++) {
+            final ArraySet<JobStatus> jobs = mTrackedJobs.valueAt(i);
+            for (int j = 0; j < jobs.size(); j++) {
+                final JobStatus js = jobs.valueAt(j);
+                if (!predicate.test(js)) {
+                    continue;
+                }
+                final long jsToken = proto.start(
+                        StateControllerProto.ConnectivityController.TRACKED_JOBS);
+                js.writeToShortProto(proto,
+                        StateControllerProto.ConnectivityController.TrackedJob.INFO);
+                proto.write(StateControllerProto.ConnectivityController.TrackedJob.SOURCE_UID,
+                        js.getSourceUid());
+                NetworkRequest rn = js.getJob().getRequiredNetwork();
+                if (rn != null) {
+                    rn.writeToProto(proto,
+                            StateControllerProto.ConnectivityController.TrackedJob
+                                    .REQUIRED_NETWORK);
+                }
+                proto.end(jsToken);
+            }
+        }
+
+        proto.end(mToken);
+        proto.end(token);
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/ContentObserverController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/ContentObserverController.java
new file mode 100644
index 0000000..a775cf5
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/ContentObserverController.java
@@ -0,0 +1,544 @@
+/*
+ * Copyright (C) 2016 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.controllers;
+
+import android.annotation.UserIdInt;
+import android.app.job.JobInfo;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.UserHandle;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.Slog;
+import android.util.SparseArray;
+import android.util.TimeUtils;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.StateControllerProto;
+import com.android.server.job.StateControllerProto.ContentObserverController.Observer.TriggerContentData;
+
+import java.util.ArrayList;
+import java.util.function.Predicate;
+
+/**
+ * Controller for monitoring changes to content URIs through a ContentObserver.
+ */
+public final class ContentObserverController extends StateController {
+    private static final String TAG = "JobScheduler.ContentObserver";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG
+            || Log.isLoggable(TAG, Log.DEBUG);
+
+    /**
+     * Maximum number of changing URIs we will batch together to report.
+     * XXX Should be smarter about this, restricting it by the maximum number
+     * of characters we will retain.
+     */
+    private static final int MAX_URIS_REPORTED = 50;
+
+    /**
+     * At this point we consider it urgent to schedule the job ASAP.
+     */
+    private static final int URIS_URGENT_THRESHOLD = 40;
+
+    final private ArraySet<JobStatus> mTrackedTasks = new ArraySet<>();
+    /**
+     * Per-userid {@link JobInfo.TriggerContentUri} keyed ContentObserver cache.
+     */
+    final SparseArray<ArrayMap<JobInfo.TriggerContentUri, ObserverInstance>> mObservers =
+            new SparseArray<>();
+    final Handler mHandler;
+
+    public ContentObserverController(JobSchedulerService service) {
+        super(service);
+        mHandler = new Handler(mContext.getMainLooper());
+    }
+
+    @Override
+    public void maybeStartTrackingJobLocked(JobStatus taskStatus, JobStatus lastJob) {
+        if (taskStatus.hasContentTriggerConstraint()) {
+            if (taskStatus.contentObserverJobInstance == null) {
+                taskStatus.contentObserverJobInstance = new JobInstance(taskStatus);
+            }
+            if (DEBUG) {
+                Slog.i(TAG, "Tracking content-trigger job " + taskStatus);
+            }
+            mTrackedTasks.add(taskStatus);
+            taskStatus.setTrackingController(JobStatus.TRACKING_CONTENT);
+            boolean havePendingUris = false;
+            // If there is a previous job associated with the new job, propagate over
+            // any pending content URI trigger reports.
+            if (taskStatus.contentObserverJobInstance.mChangedAuthorities != null) {
+                havePendingUris = true;
+            }
+            // If we have previously reported changed authorities/uris, then we failed
+            // to complete the job with them so will re-record them to report again.
+            if (taskStatus.changedAuthorities != null) {
+                havePendingUris = true;
+                if (taskStatus.contentObserverJobInstance.mChangedAuthorities == null) {
+                    taskStatus.contentObserverJobInstance.mChangedAuthorities
+                            = new ArraySet<>();
+                }
+                for (String auth : taskStatus.changedAuthorities) {
+                    taskStatus.contentObserverJobInstance.mChangedAuthorities.add(auth);
+                }
+                if (taskStatus.changedUris != null) {
+                    if (taskStatus.contentObserverJobInstance.mChangedUris == null) {
+                        taskStatus.contentObserverJobInstance.mChangedUris = new ArraySet<>();
+                    }
+                    for (Uri uri : taskStatus.changedUris) {
+                        taskStatus.contentObserverJobInstance.mChangedUris.add(uri);
+                    }
+                }
+                taskStatus.changedAuthorities = null;
+                taskStatus.changedUris = null;
+            }
+            taskStatus.changedAuthorities = null;
+            taskStatus.changedUris = null;
+            taskStatus.setContentTriggerConstraintSatisfied(havePendingUris);
+        }
+        if (lastJob != null && lastJob.contentObserverJobInstance != null) {
+            // And now we can detach the instance state from the last job.
+            lastJob.contentObserverJobInstance.detachLocked();
+            lastJob.contentObserverJobInstance = null;
+        }
+    }
+
+    @Override
+    public void prepareForExecutionLocked(JobStatus taskStatus) {
+        if (taskStatus.hasContentTriggerConstraint()) {
+            if (taskStatus.contentObserverJobInstance != null) {
+                taskStatus.changedUris = taskStatus.contentObserverJobInstance.mChangedUris;
+                taskStatus.changedAuthorities
+                        = taskStatus.contentObserverJobInstance.mChangedAuthorities;
+                taskStatus.contentObserverJobInstance.mChangedUris = null;
+                taskStatus.contentObserverJobInstance.mChangedAuthorities = null;
+            }
+        }
+    }
+
+    @Override
+    public void maybeStopTrackingJobLocked(JobStatus taskStatus, JobStatus incomingJob,
+            boolean forUpdate) {
+        if (taskStatus.clearTrackingController(JobStatus.TRACKING_CONTENT)) {
+            mTrackedTasks.remove(taskStatus);
+            if (taskStatus.contentObserverJobInstance != null) {
+                taskStatus.contentObserverJobInstance.unscheduleLocked();
+                if (incomingJob != null) {
+                    if (taskStatus.contentObserverJobInstance != null
+                            && taskStatus.contentObserverJobInstance.mChangedAuthorities != null) {
+                        // We are stopping this job, but it is going to be replaced by this given
+                        // incoming job.  We want to propagate our state over to it, so we don't
+                        // lose any content changes that had happened since the last one started.
+                        // If there is a previous job associated with the new job, propagate over
+                        // any pending content URI trigger reports.
+                        if (incomingJob.contentObserverJobInstance == null) {
+                            incomingJob.contentObserverJobInstance = new JobInstance(incomingJob);
+                        }
+                        incomingJob.contentObserverJobInstance.mChangedAuthorities
+                                = taskStatus.contentObserverJobInstance.mChangedAuthorities;
+                        incomingJob.contentObserverJobInstance.mChangedUris
+                                = taskStatus.contentObserverJobInstance.mChangedUris;
+                        taskStatus.contentObserverJobInstance.mChangedAuthorities = null;
+                        taskStatus.contentObserverJobInstance.mChangedUris = null;
+                    }
+                    // We won't detach the content observers here, because we want to
+                    // allow them to continue monitoring so we don't miss anything...  and
+                    // since we are giving an incomingJob here, we know this will be
+                    // immediately followed by a start tracking of that job.
+                } else {
+                    // But here there is no incomingJob, so nothing coming up, so time to detach.
+                    taskStatus.contentObserverJobInstance.detachLocked();
+                    taskStatus.contentObserverJobInstance = null;
+                }
+            }
+            if (DEBUG) {
+                Slog.i(TAG, "No longer tracking job " + taskStatus);
+            }
+        }
+    }
+
+    @Override
+    public void rescheduleForFailureLocked(JobStatus newJob, JobStatus failureToReschedule) {
+        if (failureToReschedule.hasContentTriggerConstraint()
+                && newJob.hasContentTriggerConstraint()) {
+            // Our job has failed, and we are scheduling a new job for it.
+            // Copy the last reported content changes in to the new job, so when
+            // we schedule the new one we will pick them up and report them again.
+            newJob.changedAuthorities = failureToReschedule.changedAuthorities;
+            newJob.changedUris = failureToReschedule.changedUris;
+        }
+    }
+
+    final class ObserverInstance extends ContentObserver {
+        final JobInfo.TriggerContentUri mUri;
+        final @UserIdInt int mUserId;
+        final ArraySet<JobInstance> mJobs = new ArraySet<>();
+
+        public ObserverInstance(Handler handler, JobInfo.TriggerContentUri uri,
+                @UserIdInt int userId) {
+            super(handler);
+            mUri = uri;
+            mUserId = userId;
+        }
+
+        @Override
+        public void onChange(boolean selfChange, Uri uri) {
+            if (DEBUG) {
+                Slog.i(TAG, "onChange(self=" + selfChange + ") for " + uri
+                        + " when mUri=" + mUri + " mUserId=" + mUserId);
+            }
+            synchronized (mLock) {
+                final int N = mJobs.size();
+                for (int i=0; i<N; i++) {
+                    JobInstance inst = mJobs.valueAt(i);
+                    if (inst.mChangedUris == null) {
+                        inst.mChangedUris = new ArraySet<>();
+                    }
+                    if (inst.mChangedUris.size() < MAX_URIS_REPORTED) {
+                        inst.mChangedUris.add(uri);
+                    }
+                    if (inst.mChangedAuthorities == null) {
+                        inst.mChangedAuthorities = new ArraySet<>();
+                    }
+                    inst.mChangedAuthorities.add(uri.getAuthority());
+                    inst.scheduleLocked();
+                }
+            }
+        }
+    }
+
+    static final class TriggerRunnable implements Runnable {
+        final JobInstance mInstance;
+
+        TriggerRunnable(JobInstance instance) {
+            mInstance = instance;
+        }
+
+        @Override public void run() {
+            mInstance.trigger();
+        }
+    }
+
+    final class JobInstance {
+        final ArrayList<ObserverInstance> mMyObservers = new ArrayList<>();
+        final JobStatus mJobStatus;
+        final Runnable mExecuteRunner;
+        final Runnable mTimeoutRunner;
+        ArraySet<Uri> mChangedUris;
+        ArraySet<String> mChangedAuthorities;
+
+        boolean mTriggerPending;
+
+        // This constructor must be called with the master job scheduler lock held.
+        JobInstance(JobStatus jobStatus) {
+            mJobStatus = jobStatus;
+            mExecuteRunner = new TriggerRunnable(this);
+            mTimeoutRunner = new TriggerRunnable(this);
+            final JobInfo.TriggerContentUri[] uris = jobStatus.getJob().getTriggerContentUris();
+            final int sourceUserId = jobStatus.getSourceUserId();
+            ArrayMap<JobInfo.TriggerContentUri, ObserverInstance> observersOfUser =
+                    mObservers.get(sourceUserId);
+            if (observersOfUser == null) {
+                observersOfUser = new ArrayMap<>();
+                mObservers.put(sourceUserId, observersOfUser);
+            }
+            if (uris != null) {
+                for (JobInfo.TriggerContentUri uri : uris) {
+                    ObserverInstance obs = observersOfUser.get(uri);
+                    if (obs == null) {
+                        obs = new ObserverInstance(mHandler, uri, jobStatus.getSourceUserId());
+                        observersOfUser.put(uri, obs);
+                        final boolean andDescendants = (uri.getFlags() &
+                                JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS) != 0;
+                        if (DEBUG) {
+                            Slog.v(TAG, "New observer " + obs + " for " + uri.getUri()
+                                    + " andDescendants=" + andDescendants
+                                    + " sourceUserId=" + sourceUserId);
+                        }
+                        mContext.getContentResolver().registerContentObserver(
+                                uri.getUri(),
+                                andDescendants,
+                                obs,
+                                sourceUserId
+                        );
+                    } else {
+                        if (DEBUG) {
+                            final boolean andDescendants = (uri.getFlags() &
+                                    JobInfo.TriggerContentUri.FLAG_NOTIFY_FOR_DESCENDANTS) != 0;
+                            Slog.v(TAG, "Reusing existing observer " + obs + " for " + uri.getUri()
+                                    + " andDescendants=" + andDescendants);
+                        }
+                    }
+                    obs.mJobs.add(this);
+                    mMyObservers.add(obs);
+                }
+            }
+        }
+
+        void trigger() {
+            boolean reportChange = false;
+            synchronized (mLock) {
+                if (mTriggerPending) {
+                    if (mJobStatus.setContentTriggerConstraintSatisfied(true)) {
+                        reportChange = true;
+                    }
+                    unscheduleLocked();
+                }
+            }
+            // Let the scheduler know that state has changed. This may or may not result in an
+            // execution.
+            if (reportChange) {
+                mStateChangedListener.onControllerStateChanged();
+            }
+        }
+
+        void scheduleLocked() {
+            if (!mTriggerPending) {
+                mTriggerPending = true;
+                mHandler.postDelayed(mTimeoutRunner, mJobStatus.getTriggerContentMaxDelay());
+            }
+            mHandler.removeCallbacks(mExecuteRunner);
+            if (mChangedUris.size() >= URIS_URGENT_THRESHOLD) {
+                // If we start getting near the limit, GO NOW!
+                mHandler.post(mExecuteRunner);
+            } else {
+                mHandler.postDelayed(mExecuteRunner, mJobStatus.getTriggerContentUpdateDelay());
+            }
+        }
+
+        void unscheduleLocked() {
+            if (mTriggerPending) {
+                mHandler.removeCallbacks(mExecuteRunner);
+                mHandler.removeCallbacks(mTimeoutRunner);
+                mTriggerPending = false;
+            }
+        }
+
+        void detachLocked() {
+            final int N = mMyObservers.size();
+            for (int i=0; i<N; i++) {
+                final ObserverInstance obs = mMyObservers.get(i);
+                obs.mJobs.remove(this);
+                if (obs.mJobs.size() == 0) {
+                    if (DEBUG) {
+                        Slog.i(TAG, "Unregistering observer " + obs + " for " + obs.mUri.getUri());
+                    }
+                    mContext.getContentResolver().unregisterContentObserver(obs);
+                    ArrayMap<JobInfo.TriggerContentUri, ObserverInstance> observerOfUser =
+                            mObservers.get(obs.mUserId);
+                    if (observerOfUser !=  null) {
+                        observerOfUser.remove(obs.mUri);
+                    }
+                }
+            }
+        }
+    }
+
+    @Override
+    public void dumpControllerStateLocked(IndentingPrintWriter pw,
+            Predicate<JobStatus> predicate) {
+        for (int i = 0; i < mTrackedTasks.size(); i++) {
+            JobStatus js = mTrackedTasks.valueAt(i);
+            if (!predicate.test(js)) {
+                continue;
+            }
+            pw.print("#");
+            js.printUniqueId(pw);
+            pw.print(" from ");
+            UserHandle.formatUid(pw, js.getSourceUid());
+            pw.println();
+        }
+        pw.println();
+
+        int N = mObservers.size();
+        if (N > 0) {
+            pw.println("Observers:");
+            pw.increaseIndent();
+            for (int userIdx = 0; userIdx < N; userIdx++) {
+                final int userId = mObservers.keyAt(userIdx);
+                ArrayMap<JobInfo.TriggerContentUri, ObserverInstance> observersOfUser =
+                        mObservers.get(userId);
+                int numbOfObserversPerUser = observersOfUser.size();
+                for (int observerIdx = 0 ; observerIdx < numbOfObserversPerUser; observerIdx++) {
+                    ObserverInstance obs = observersOfUser.valueAt(observerIdx);
+                    int M = obs.mJobs.size();
+                    boolean shouldDump = false;
+                    for (int j = 0; j < M; j++) {
+                        JobInstance inst = obs.mJobs.valueAt(j);
+                        if (predicate.test(inst.mJobStatus)) {
+                            shouldDump = true;
+                            break;
+                        }
+                    }
+                    if (!shouldDump) {
+                        continue;
+                    }
+                    JobInfo.TriggerContentUri trigger = observersOfUser.keyAt(observerIdx);
+                    pw.print(trigger.getUri());
+                    pw.print(" 0x");
+                    pw.print(Integer.toHexString(trigger.getFlags()));
+                    pw.print(" (");
+                    pw.print(System.identityHashCode(obs));
+                    pw.println("):");
+                    pw.increaseIndent();
+                    pw.println("Jobs:");
+                    pw.increaseIndent();
+                    for (int j = 0; j < M; j++) {
+                        JobInstance inst = obs.mJobs.valueAt(j);
+                        pw.print("#");
+                        inst.mJobStatus.printUniqueId(pw);
+                        pw.print(" from ");
+                        UserHandle.formatUid(pw, inst.mJobStatus.getSourceUid());
+                        if (inst.mChangedAuthorities != null) {
+                            pw.println(":");
+                            pw.increaseIndent();
+                            if (inst.mTriggerPending) {
+                                pw.print("Trigger pending: update=");
+                                TimeUtils.formatDuration(
+                                        inst.mJobStatus.getTriggerContentUpdateDelay(), pw);
+                                pw.print(", max=");
+                                TimeUtils.formatDuration(
+                                        inst.mJobStatus.getTriggerContentMaxDelay(), pw);
+                                pw.println();
+                            }
+                            pw.println("Changed Authorities:");
+                            for (int k = 0; k < inst.mChangedAuthorities.size(); k++) {
+                                pw.println(inst.mChangedAuthorities.valueAt(k));
+                            }
+                            if (inst.mChangedUris != null) {
+                                pw.println("          Changed URIs:");
+                                for (int k = 0; k < inst.mChangedUris.size(); k++) {
+                                    pw.println(inst.mChangedUris.valueAt(k));
+                                }
+                            }
+                            pw.decreaseIndent();
+                        } else {
+                            pw.println();
+                        }
+                    }
+                    pw.decreaseIndent();
+                    pw.decreaseIndent();
+                }
+            }
+            pw.decreaseIndent();
+        }
+    }
+
+    @Override
+    public void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+            Predicate<JobStatus> predicate) {
+        final long token = proto.start(fieldId);
+        final long mToken = proto.start(StateControllerProto.CONTENT_OBSERVER);
+
+        for (int i = 0; i < mTrackedTasks.size(); i++) {
+            JobStatus js = mTrackedTasks.valueAt(i);
+            if (!predicate.test(js)) {
+                continue;
+            }
+            final long jsToken =
+                    proto.start(StateControllerProto.ContentObserverController.TRACKED_JOBS);
+            js.writeToShortProto(proto,
+                    StateControllerProto.ContentObserverController.TrackedJob.INFO);
+            proto.write(StateControllerProto.ContentObserverController.TrackedJob.SOURCE_UID,
+                    js.getSourceUid());
+            proto.end(jsToken);
+        }
+
+        final int n = mObservers.size();
+        for (int userIdx = 0; userIdx < n; userIdx++) {
+            final long oToken =
+                    proto.start(StateControllerProto.ContentObserverController.OBSERVERS);
+            final int userId = mObservers.keyAt(userIdx);
+
+            proto.write(StateControllerProto.ContentObserverController.Observer.USER_ID, userId);
+
+            ArrayMap<JobInfo.TriggerContentUri, ObserverInstance> observersOfUser =
+                    mObservers.get(userId);
+            int numbOfObserversPerUser = observersOfUser.size();
+            for (int observerIdx = 0 ; observerIdx < numbOfObserversPerUser; observerIdx++) {
+                ObserverInstance obs = observersOfUser.valueAt(observerIdx);
+                int m = obs.mJobs.size();
+                boolean shouldDump = false;
+                for (int j = 0; j < m; j++) {
+                    JobInstance inst = obs.mJobs.valueAt(j);
+                    if (predicate.test(inst.mJobStatus)) {
+                        shouldDump = true;
+                        break;
+                    }
+                }
+                if (!shouldDump) {
+                    continue;
+                }
+                final long tToken = proto.start(
+                        StateControllerProto.ContentObserverController.Observer.TRIGGERS);
+
+                JobInfo.TriggerContentUri trigger = observersOfUser.keyAt(observerIdx);
+                Uri u = trigger.getUri();
+                if (u != null) {
+                    proto.write(TriggerContentData.URI, u.toString());
+                }
+                proto.write(TriggerContentData.FLAGS, trigger.getFlags());
+
+                for (int j = 0; j < m; j++) {
+                    final long jToken = proto.start(TriggerContentData.JOBS);
+                    JobInstance inst = obs.mJobs.valueAt(j);
+
+                    inst.mJobStatus.writeToShortProto(proto, TriggerContentData.JobInstance.INFO);
+                    proto.write(TriggerContentData.JobInstance.SOURCE_UID,
+                            inst.mJobStatus.getSourceUid());
+
+                    if (inst.mChangedAuthorities == null) {
+                        proto.end(jToken);
+                        continue;
+                    }
+                    if (inst.mTriggerPending) {
+                        proto.write(TriggerContentData.JobInstance.TRIGGER_CONTENT_UPDATE_DELAY_MS,
+                                inst.mJobStatus.getTriggerContentUpdateDelay());
+                        proto.write(TriggerContentData.JobInstance.TRIGGER_CONTENT_MAX_DELAY_MS,
+                                inst.mJobStatus.getTriggerContentMaxDelay());
+                    }
+                    for (int k = 0; k < inst.mChangedAuthorities.size(); k++) {
+                        proto.write(TriggerContentData.JobInstance.CHANGED_AUTHORITIES,
+                                inst.mChangedAuthorities.valueAt(k));
+                    }
+                    if (inst.mChangedUris != null) {
+                        for (int k = 0; k < inst.mChangedUris.size(); k++) {
+                            u = inst.mChangedUris.valueAt(k);
+                            if (u != null) {
+                                proto.write(TriggerContentData.JobInstance.CHANGED_URIS,
+                                        u.toString());
+                            }
+                        }
+                    }
+
+                    proto.end(jToken);
+                }
+
+                proto.end(tToken);
+            }
+
+            proto.end(oToken);
+        }
+
+        proto.end(mToken);
+        proto.end(token);
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/DeviceIdleJobsController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/DeviceIdleJobsController.java
new file mode 100644
index 0000000..127a5c8
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/DeviceIdleJobsController.java
@@ -0,0 +1,314 @@
+/*
+ * Copyright (C) 2016 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.controllers;
+
+import android.app.job.JobInfo;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.PowerManager;
+import android.os.UserHandle;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.Slog;
+import android.util.SparseBooleanArray;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.util.ArrayUtils;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.DeviceIdleController;
+import com.android.server.LocalServices;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.StateControllerProto;
+import com.android.server.job.StateControllerProto.DeviceIdleJobsController.TrackedJob;
+
+import java.util.Arrays;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * When device is dozing, set constraint for all jobs, except whitelisted apps, as not satisfied.
+ * When device is not dozing, set constraint for all jobs as satisfied.
+ */
+public final class DeviceIdleJobsController extends StateController {
+    private static final String TAG = "JobScheduler.DeviceIdle";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG
+            || Log.isLoggable(TAG, Log.DEBUG);
+
+    private static final long BACKGROUND_JOBS_DELAY = 3000;
+
+    static final int PROCESS_BACKGROUND_JOBS = 1;
+
+    /**
+     * These are jobs added with a special flag to indicate that they should be exempted from doze
+     * when the app is temp whitelisted or in the foreground.
+     */
+    private final ArraySet<JobStatus> mAllowInIdleJobs;
+    private final SparseBooleanArray mForegroundUids;
+    private final DeviceIdleUpdateFunctor mDeviceIdleUpdateFunctor;
+    private final DeviceIdleJobsDelayHandler mHandler;
+    private final PowerManager mPowerManager;
+    private final DeviceIdleController.LocalService mLocalDeviceIdleController;
+
+    /**
+     * True when in device idle mode, so we don't want to schedule any jobs.
+     */
+    private boolean mDeviceIdleMode;
+    private int[] mDeviceIdleWhitelistAppIds;
+    private int[] mPowerSaveTempWhitelistAppIds;
+
+    // onReceive
+    private final BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            switch (intent.getAction()) {
+                case PowerManager.ACTION_LIGHT_DEVICE_IDLE_MODE_CHANGED:
+                case PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED:
+                    updateIdleMode(mPowerManager != null && (mPowerManager.isDeviceIdleMode()
+                            || mPowerManager.isLightDeviceIdleMode()));
+                    break;
+                case PowerManager.ACTION_POWER_SAVE_WHITELIST_CHANGED:
+                    synchronized (mLock) {
+                        mDeviceIdleWhitelistAppIds =
+                                mLocalDeviceIdleController.getPowerSaveWhitelistUserAppIds();
+                        if (DEBUG) {
+                            Slog.d(TAG, "Got whitelist "
+                                    + Arrays.toString(mDeviceIdleWhitelistAppIds));
+                        }
+                    }
+                    break;
+                case PowerManager.ACTION_POWER_SAVE_TEMP_WHITELIST_CHANGED:
+                    synchronized (mLock) {
+                        mPowerSaveTempWhitelistAppIds =
+                                mLocalDeviceIdleController.getPowerSaveTempWhitelistAppIds();
+                        if (DEBUG) {
+                            Slog.d(TAG, "Got temp whitelist "
+                                    + Arrays.toString(mPowerSaveTempWhitelistAppIds));
+                        }
+                        boolean changed = false;
+                        for (int i = 0; i < mAllowInIdleJobs.size(); i++) {
+                            changed |= updateTaskStateLocked(mAllowInIdleJobs.valueAt(i));
+                        }
+                        if (changed) {
+                            mStateChangedListener.onControllerStateChanged();
+                        }
+                    }
+                    break;
+            }
+        }
+    };
+
+    public DeviceIdleJobsController(JobSchedulerService service) {
+        super(service);
+
+        mHandler = new DeviceIdleJobsDelayHandler(mContext.getMainLooper());
+        // Register for device idle mode changes
+        mPowerManager = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
+        mLocalDeviceIdleController =
+                LocalServices.getService(DeviceIdleController.LocalService.class);
+        mDeviceIdleWhitelistAppIds = mLocalDeviceIdleController.getPowerSaveWhitelistUserAppIds();
+        mPowerSaveTempWhitelistAppIds =
+                mLocalDeviceIdleController.getPowerSaveTempWhitelistAppIds();
+        mDeviceIdleUpdateFunctor = new DeviceIdleUpdateFunctor();
+        mAllowInIdleJobs = new ArraySet<>();
+        mForegroundUids = new SparseBooleanArray();
+        final IntentFilter filter = new IntentFilter();
+        filter.addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED);
+        filter.addAction(PowerManager.ACTION_LIGHT_DEVICE_IDLE_MODE_CHANGED);
+        filter.addAction(PowerManager.ACTION_POWER_SAVE_WHITELIST_CHANGED);
+        filter.addAction(PowerManager.ACTION_POWER_SAVE_TEMP_WHITELIST_CHANGED);
+        mContext.registerReceiverAsUser(
+                mBroadcastReceiver, UserHandle.ALL, filter, null, null);
+    }
+
+    void updateIdleMode(boolean enabled) {
+        boolean changed = false;
+        synchronized (mLock) {
+            if (mDeviceIdleMode != enabled) {
+                changed = true;
+            }
+            mDeviceIdleMode = enabled;
+            if (DEBUG) Slog.d(TAG, "mDeviceIdleMode=" + mDeviceIdleMode);
+            if (enabled) {
+                mHandler.removeMessages(PROCESS_BACKGROUND_JOBS);
+                mService.getJobStore().forEachJob(mDeviceIdleUpdateFunctor);
+            } else {
+                // When coming out of doze, process all foreground uids immediately, while others
+                // will be processed after a delay of 3 seconds.
+                for (int i = 0; i < mForegroundUids.size(); i++) {
+                    if (mForegroundUids.valueAt(i)) {
+                        mService.getJobStore().forEachJobForSourceUid(
+                                mForegroundUids.keyAt(i), mDeviceIdleUpdateFunctor);
+                    }
+                }
+                mHandler.sendEmptyMessageDelayed(PROCESS_BACKGROUND_JOBS, BACKGROUND_JOBS_DELAY);
+            }
+        }
+        // Inform the job scheduler service about idle mode changes
+        if (changed) {
+            mStateChangedListener.onDeviceIdleStateChanged(enabled);
+        }
+    }
+
+    /**
+     *  Called by jobscheduler service to report uid state changes between active and idle
+     */
+    public void setUidActiveLocked(int uid, boolean active) {
+        final boolean changed = (active != mForegroundUids.get(uid));
+        if (!changed) {
+            return;
+        }
+        if (DEBUG) {
+            Slog.d(TAG, "uid " + uid + " going " + (active ? "active" : "inactive"));
+        }
+        mForegroundUids.put(uid, active);
+        mDeviceIdleUpdateFunctor.mChanged = false;
+        mService.getJobStore().forEachJobForSourceUid(uid, mDeviceIdleUpdateFunctor);
+        if (mDeviceIdleUpdateFunctor.mChanged) {
+            mStateChangedListener.onControllerStateChanged();
+        }
+    }
+
+    /**
+     * Checks if the given job's scheduling app id exists in the device idle user whitelist.
+     */
+    boolean isWhitelistedLocked(JobStatus job) {
+        return Arrays.binarySearch(mDeviceIdleWhitelistAppIds,
+                UserHandle.getAppId(job.getSourceUid())) >= 0;
+    }
+
+    /**
+     * Checks if the given job's scheduling app id exists in the device idle temp whitelist.
+     */
+    boolean isTempWhitelistedLocked(JobStatus job) {
+        return ArrayUtils.contains(mPowerSaveTempWhitelistAppIds,
+                UserHandle.getAppId(job.getSourceUid()));
+    }
+
+    private boolean updateTaskStateLocked(JobStatus task) {
+        final boolean allowInIdle = ((task.getFlags()&JobInfo.FLAG_IMPORTANT_WHILE_FOREGROUND) != 0)
+                && (mForegroundUids.get(task.getSourceUid()) || isTempWhitelistedLocked(task));
+        final boolean whitelisted = isWhitelistedLocked(task);
+        final boolean enableTask = !mDeviceIdleMode || whitelisted || allowInIdle;
+        return task.setDeviceNotDozingConstraintSatisfied(enableTask, whitelisted);
+    }
+
+    @Override
+    public void maybeStartTrackingJobLocked(JobStatus jobStatus, JobStatus lastJob) {
+        if ((jobStatus.getFlags()&JobInfo.FLAG_IMPORTANT_WHILE_FOREGROUND) != 0) {
+            mAllowInIdleJobs.add(jobStatus);
+        }
+        updateTaskStateLocked(jobStatus);
+    }
+
+    @Override
+    public void maybeStopTrackingJobLocked(JobStatus jobStatus, JobStatus incomingJob,
+            boolean forUpdate) {
+        if ((jobStatus.getFlags()&JobInfo.FLAG_IMPORTANT_WHILE_FOREGROUND) != 0) {
+            mAllowInIdleJobs.remove(jobStatus);
+        }
+    }
+
+    @Override
+    public void dumpControllerStateLocked(final IndentingPrintWriter pw,
+            final Predicate<JobStatus> predicate) {
+        pw.println("Idle mode: " + mDeviceIdleMode);
+        pw.println();
+
+        mService.getJobStore().forEachJob(predicate, (jobStatus) -> {
+            pw.print("#");
+            jobStatus.printUniqueId(pw);
+            pw.print(" from ");
+            UserHandle.formatUid(pw, jobStatus.getSourceUid());
+            pw.print(": ");
+            pw.print(jobStatus.getSourcePackageName());
+            pw.print((jobStatus.satisfiedConstraints
+                    & JobStatus.CONSTRAINT_DEVICE_NOT_DOZING) != 0
+                            ? " RUNNABLE" : " WAITING");
+            if (jobStatus.dozeWhitelisted) {
+                pw.print(" WHITELISTED");
+            }
+            if (mAllowInIdleJobs.contains(jobStatus)) {
+                pw.print(" ALLOWED_IN_DOZE");
+            }
+            pw.println();
+        });
+    }
+
+    @Override
+    public void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+            Predicate<JobStatus> predicate) {
+        final long token = proto.start(fieldId);
+        final long mToken = proto.start(StateControllerProto.DEVICE_IDLE);
+
+        proto.write(StateControllerProto.DeviceIdleJobsController.IS_DEVICE_IDLE_MODE,
+                mDeviceIdleMode);
+        mService.getJobStore().forEachJob(predicate, (jobStatus) -> {
+            final long jsToken =
+                    proto.start(StateControllerProto.DeviceIdleJobsController.TRACKED_JOBS);
+
+            jobStatus.writeToShortProto(proto, TrackedJob.INFO);
+            proto.write(TrackedJob.SOURCE_UID, jobStatus.getSourceUid());
+            proto.write(TrackedJob.SOURCE_PACKAGE_NAME, jobStatus.getSourcePackageName());
+            proto.write(TrackedJob.ARE_CONSTRAINTS_SATISFIED,
+                    (jobStatus.satisfiedConstraints &
+                        JobStatus.CONSTRAINT_DEVICE_NOT_DOZING) != 0);
+            proto.write(TrackedJob.IS_DOZE_WHITELISTED, jobStatus.dozeWhitelisted);
+            proto.write(TrackedJob.IS_ALLOWED_IN_DOZE, mAllowInIdleJobs.contains(jobStatus));
+
+            proto.end(jsToken);
+        });
+
+        proto.end(mToken);
+        proto.end(token);
+    }
+
+    final class DeviceIdleUpdateFunctor implements Consumer<JobStatus> {
+        boolean mChanged;
+
+        @Override
+        public void accept(JobStatus jobStatus) {
+            mChanged |= updateTaskStateLocked(jobStatus);
+        }
+    }
+
+    final class DeviceIdleJobsDelayHandler extends Handler {
+        public DeviceIdleJobsDelayHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            switch (msg.what) {
+                case PROCESS_BACKGROUND_JOBS:
+                    // Just process all the jobs, the ones in foreground should already be running.
+                    synchronized (mLock) {
+                        mDeviceIdleUpdateFunctor.mChanged = false;
+                        mService.getJobStore().forEachJob(mDeviceIdleUpdateFunctor);
+                        if (mDeviceIdleUpdateFunctor.mChanged) {
+                            mStateChangedListener.onControllerStateChanged();
+                        }
+                    }
+                    break;
+            }
+        }
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/IdleController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/IdleController.java
new file mode 100644
index 0000000..e3c311f
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/IdleController.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2014 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.controllers;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.UserHandle;
+import android.util.ArraySet;
+import android.util.Slog;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.StateControllerProto;
+import com.android.server.job.controllers.idle.CarIdlenessTracker;
+import com.android.server.job.controllers.idle.DeviceIdlenessTracker;
+import com.android.server.job.controllers.idle.IdlenessListener;
+import com.android.server.job.controllers.idle.IdlenessTracker;
+
+import java.util.function.Predicate;
+
+public final class IdleController extends StateController implements IdlenessListener {
+    private static final String TAG = "JobScheduler.IdleController";
+    // Policy: we decide that we're "idle" if the device has been unused /
+    // screen off or dreaming or wireless charging dock idle for at least this long
+    final ArraySet<JobStatus> mTrackedTasks = new ArraySet<>();
+    IdlenessTracker mIdleTracker;
+
+    public IdleController(JobSchedulerService service) {
+        super(service);
+        initIdleStateTracking(mContext);
+    }
+
+    /**
+     * StateController interface
+     */
+    @Override
+    public void maybeStartTrackingJobLocked(JobStatus taskStatus, JobStatus lastJob) {
+        if (taskStatus.hasIdleConstraint()) {
+            mTrackedTasks.add(taskStatus);
+            taskStatus.setTrackingController(JobStatus.TRACKING_IDLE);
+            taskStatus.setIdleConstraintSatisfied(mIdleTracker.isIdle());
+        }
+    }
+
+    @Override
+    public void maybeStopTrackingJobLocked(JobStatus taskStatus, JobStatus incomingJob,
+            boolean forUpdate) {
+        if (taskStatus.clearTrackingController(JobStatus.TRACKING_IDLE)) {
+            mTrackedTasks.remove(taskStatus);
+        }
+    }
+
+    /**
+     * State-change notifications from the idleness tracker
+     */
+    @Override
+    public void reportNewIdleState(boolean isIdle) {
+        synchronized (mLock) {
+            for (int i = mTrackedTasks.size()-1; i >= 0; i--) {
+                mTrackedTasks.valueAt(i).setIdleConstraintSatisfied(isIdle);
+            }
+        }
+        mStateChangedListener.onControllerStateChanged();
+    }
+
+    /**
+     * Idle state tracking, and messaging with the task manager when
+     * significant state changes occur
+     */
+    private void initIdleStateTracking(Context ctx) {
+        final boolean isCar = mContext.getPackageManager().hasSystemFeature(
+                PackageManager.FEATURE_AUTOMOTIVE);
+        if (isCar) {
+            mIdleTracker = new CarIdlenessTracker();
+        } else {
+            mIdleTracker = new DeviceIdlenessTracker();
+        }
+        mIdleTracker.startTracking(ctx, this);
+    }
+
+    @Override
+    public void dumpControllerStateLocked(IndentingPrintWriter pw,
+            Predicate<JobStatus> predicate) {
+        pw.println("Currently idle: " + mIdleTracker.isIdle());
+        pw.println("Idleness tracker:"); mIdleTracker.dump(pw);
+        pw.println();
+
+        for (int i = 0; i < mTrackedTasks.size(); i++) {
+            final JobStatus js = mTrackedTasks.valueAt(i);
+            if (!predicate.test(js)) {
+                continue;
+            }
+            pw.print("#");
+            js.printUniqueId(pw);
+            pw.print(" from ");
+            UserHandle.formatUid(pw, js.getSourceUid());
+            pw.println();
+        }
+    }
+
+    @Override
+    public void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+            Predicate<JobStatus> predicate) {
+        final long token = proto.start(fieldId);
+        final long mToken = proto.start(StateControllerProto.IDLE);
+
+        proto.write(StateControllerProto.IdleController.IS_IDLE, mIdleTracker.isIdle());
+
+        for (int i = 0; i < mTrackedTasks.size(); i++) {
+            final JobStatus js = mTrackedTasks.valueAt(i);
+            if (!predicate.test(js)) {
+                continue;
+            }
+            final long jsToken = proto.start(StateControllerProto.IdleController.TRACKED_JOBS);
+            js.writeToShortProto(proto, StateControllerProto.IdleController.TrackedJob.INFO);
+            proto.write(StateControllerProto.IdleController.TrackedJob.SOURCE_UID,
+                    js.getSourceUid());
+            proto.end(jsToken);
+        }
+
+        proto.end(mToken);
+        proto.end(token);
+    }
+}
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
new file mode 100644
index 0000000..6f2b334
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/JobStatus.java
@@ -0,0 +1,1861 @@
+/*
+ * Copyright (C) 2014 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.controllers;
+
+import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock;
+
+import android.app.AppGlobals;
+import android.app.IActivityManager;
+import android.app.job.JobInfo;
+import android.app.job.JobWorkItem;
+import android.content.ClipData;
+import android.content.ComponentName;
+import android.net.Network;
+import android.net.Uri;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.text.format.TimeMigrationUtils;
+import android.util.ArraySet;
+import android.util.Pair;
+import android.util.Slog;
+import android.util.StatsLog;
+import android.util.TimeUtils;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.server.LocalServices;
+import com.android.server.job.GrantedUriPermissions;
+import com.android.server.job.JobSchedulerInternal;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.JobServerProtoEnums;
+import com.android.server.job.JobStatusDumpProto;
+import com.android.server.job.JobStatusShortInfoProto;
+
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.function.Predicate;
+
+/**
+ * Uniquely identifies a job internally.
+ * Created from the public {@link android.app.job.JobInfo} object when it lands on the scheduler.
+ * Contains current state of the requirements of the job, as well as a function to evaluate
+ * whether it's ready to run.
+ * This object is shared among the various controllers - hence why the different fields are atomic.
+ * This isn't strictly necessary because each controller is only interested in a specific field,
+ * and the receivers that are listening for global state change will all run on the main looper,
+ * but we don't enforce that so this is safer.
+ *
+ * Test: atest com.android.server.job.controllers.JobStatusTest
+ * @hide
+ */
+public final class JobStatus {
+    static final String TAG = "JobSchedulerService";
+    static final boolean DEBUG = JobSchedulerService.DEBUG;
+
+    public static final long NO_LATEST_RUNTIME = Long.MAX_VALUE;
+    public static final long NO_EARLIEST_RUNTIME = 0L;
+
+    static final int CONSTRAINT_CHARGING = JobInfo.CONSTRAINT_FLAG_CHARGING; // 1 < 0
+    static final int CONSTRAINT_IDLE = JobInfo.CONSTRAINT_FLAG_DEVICE_IDLE;  // 1 << 2
+    static final int CONSTRAINT_BATTERY_NOT_LOW = JobInfo.CONSTRAINT_FLAG_BATTERY_NOT_LOW; // 1 << 1
+    static final int CONSTRAINT_STORAGE_NOT_LOW = JobInfo.CONSTRAINT_FLAG_STORAGE_NOT_LOW; // 1 << 3
+    static final int CONSTRAINT_TIMING_DELAY = 1<<31;
+    static final int CONSTRAINT_DEADLINE = 1<<30;
+    static final int CONSTRAINT_CONNECTIVITY = 1<<28;
+    static final int CONSTRAINT_CONTENT_TRIGGER = 1<<26;
+    static final int CONSTRAINT_DEVICE_NOT_DOZING = 1 << 25; // Implicit constraint
+    static final int CONSTRAINT_WITHIN_QUOTA = 1 << 24;      // Implicit constraint
+    static final int CONSTRAINT_BACKGROUND_NOT_RESTRICTED = 1 << 22; // Implicit constraint
+
+    /**
+     * The constraints that we want to log to statsd.
+     *
+     * Constraints that can be inferred from other atoms have been excluded to avoid logging too
+     * much information and to reduce redundancy:
+     *
+     * * CONSTRAINT_CHARGING can be inferred with PluggedStateChanged (Atom #32)
+     * * CONSTRAINT_BATTERY_NOT_LOW can be inferred with BatteryLevelChanged (Atom #30)
+     * * CONSTRAINT_CONNECTIVITY can be partially inferred with ConnectivityStateChanged
+     * (Atom #98) and BatterySaverModeStateChanged (Atom #20).
+     * * CONSTRAINT_DEVICE_NOT_DOZING can be mostly inferred with DeviceIdleModeStateChanged
+     * (Atom #21)
+     * * CONSTRAINT_BACKGROUND_NOT_RESTRICTED can be inferred with BatterySaverModeStateChanged
+     * (Atom #20)
+     */
+    private static final int STATSD_CONSTRAINTS_TO_LOG = CONSTRAINT_CONTENT_TRIGGER
+            | CONSTRAINT_DEADLINE
+            | CONSTRAINT_IDLE
+            | CONSTRAINT_STORAGE_NOT_LOW
+            | CONSTRAINT_TIMING_DELAY
+            | CONSTRAINT_WITHIN_QUOTA;
+
+    // TODO(b/129954980)
+    private static final boolean STATS_LOG_ENABLED = false;
+
+    // Soft override: ignore constraints like time that don't affect API availability
+    public static final int OVERRIDE_SOFT = 1;
+    // Full override: ignore all constraints including API-affecting like connectivity
+    public static final int OVERRIDE_FULL = 2;
+
+    /** If not specified, trigger update delay is 10 seconds. */
+    public static final long DEFAULT_TRIGGER_UPDATE_DELAY = 10*1000;
+
+    /** The minimum possible update delay is 1/2 second. */
+    public static final long MIN_TRIGGER_UPDATE_DELAY = 500;
+
+    /** If not specified, trigger maxumum delay is 2 minutes. */
+    public static final long DEFAULT_TRIGGER_MAX_DELAY = 2*60*1000;
+
+    /** The minimum possible update delay is 1 second. */
+    public static final long MIN_TRIGGER_MAX_DELAY = 1000;
+
+    final JobInfo job;
+    /**
+     * Uid of the package requesting this job.  This can differ from the "source"
+     * uid when the job was scheduled on the app's behalf, such as with the jobs
+     * that underly Sync Manager operation.
+     */
+    final int callingUid;
+    final String batteryName;
+
+    /**
+     * Identity of the app in which the job is hosted.
+     */
+    final String sourcePackageName;
+    final int sourceUserId;
+    final int sourceUid;
+    final String sourceTag;
+
+    final String tag;
+
+    private GrantedUriPermissions uriPerms;
+    private boolean prepared;
+
+    static final boolean DEBUG_PREPARE = true;
+    private Throwable unpreparedPoint = null;
+
+    /**
+     * Earliest point in the future at which this job will be eligible to run. A value of 0
+     * indicates there is no delay constraint. See {@link #hasTimingDelayConstraint()}.
+     */
+    private final long earliestRunTimeElapsedMillis;
+    /**
+     * Latest point in the future at which this job must be run. A value of {@link Long#MAX_VALUE}
+     * indicates there is no deadline constraint. See {@link #hasDeadlineConstraint()}.
+     */
+    private final long latestRunTimeElapsedMillis;
+
+    /**
+     * Valid only for periodic jobs. The original latest point in the future at which this
+     * job was expected to run.
+     */
+    private long mOriginalLatestRunTimeElapsedMillis;
+
+    /** How many times this job has failed, used to compute back-off. */
+    private final int numFailures;
+
+    /**
+     * Current standby heartbeat when this job was scheduled or last ran.  Used to
+     * pin the runnability check regardless of the job's app moving between buckets.
+     */
+    private final long baseHeartbeat;
+
+    /**
+     * Which app standby bucket this job's app is in.  Updated when the app is moved to a
+     * different bucket.
+     */
+    private int standbyBucket;
+
+    /**
+     * Debugging: timestamp if we ever defer this job based on standby bucketing, this
+     * is when we did so.
+     */
+    private long whenStandbyDeferred;
+
+    /** The first time this job was force batched. */
+    private long mFirstForceBatchedTimeElapsed;
+
+    // Constraints.
+    final int requiredConstraints;
+    private final int mRequiredConstraintsOfInterest;
+    int satisfiedConstraints = 0;
+    private int mSatisfiedConstraintsOfInterest = 0;
+
+    // Set to true if doze constraint was satisfied due to app being whitelisted.
+    public boolean dozeWhitelisted;
+
+    // Set to true when the app is "active" per AppStateTracker
+    public boolean uidActive;
+
+    /**
+     * Flag for {@link #trackingControllers}: the battery controller is currently tracking this job.
+     */
+    public static final int TRACKING_BATTERY = 1<<0;
+    /**
+     * Flag for {@link #trackingControllers}: the network connectivity controller is currently
+     * tracking this job.
+     */
+    public static final int TRACKING_CONNECTIVITY = 1<<1;
+    /**
+     * Flag for {@link #trackingControllers}: the content observer controller is currently
+     * tracking this job.
+     */
+    public static final int TRACKING_CONTENT = 1<<2;
+    /**
+     * Flag for {@link #trackingControllers}: the idle controller is currently tracking this job.
+     */
+    public static final int TRACKING_IDLE = 1<<3;
+    /**
+     * Flag for {@link #trackingControllers}: the storage controller is currently tracking this job.
+     */
+    public static final int TRACKING_STORAGE = 1<<4;
+    /**
+     * Flag for {@link #trackingControllers}: the time controller is currently tracking this job.
+     */
+    public static final int TRACKING_TIME = 1<<5;
+    /**
+     * Flag for {@link #trackingControllers}: the quota controller is currently tracking this job.
+     */
+    public static final int TRACKING_QUOTA = 1 << 6;
+
+    /**
+     * Bit mask of controllers that are currently tracking the job.
+     */
+    private int trackingControllers;
+
+    /**
+     * Flag for {@link #mInternalFlags}: this job was scheduled when the app that owns the job
+     * service (not necessarily the caller) was in the foreground and the job has no time
+     * constraints, which makes it exempted from the battery saver job restriction.
+     *
+     * @hide
+     */
+    public static final int INTERNAL_FLAG_HAS_FOREGROUND_EXEMPTION = 1 << 0;
+
+    /**
+     * Versatile, persistable flags for a job that's updated within the system server,
+     * as opposed to {@link JobInfo#flags} that's set by callers.
+     */
+    private int mInternalFlags;
+
+    // These are filled in by controllers when preparing for execution.
+    public ArraySet<Uri> changedUris;
+    public ArraySet<String> changedAuthorities;
+    public Network network;
+
+    public int lastEvaluatedPriority;
+
+    // If non-null, this is work that has been enqueued for the job.
+    public ArrayList<JobWorkItem> pendingWork;
+
+    // If non-null, this is work that is currently being executed.
+    public ArrayList<JobWorkItem> executingWork;
+
+    public int nextPendingWorkId = 1;
+
+    // Used by shell commands
+    public int overrideState = 0;
+
+    // When this job was enqueued, for ordering.  (in elapsedRealtimeMillis)
+    public long enqueueTime;
+
+    // Metrics about queue latency.  (in uptimeMillis)
+    public long madePending;
+    public long madeActive;
+
+    /**
+     * Last time a job finished successfully for a periodic job, in the currentTimeMillis time,
+     * for dumpsys.
+     */
+    private long mLastSuccessfulRunTime;
+
+    /**
+     * Last time a job finished unsuccessfully, in the currentTimeMillis time, for dumpsys.
+     */
+    private long mLastFailedRunTime;
+
+    /**
+     * Transient: when a job is inflated from disk before we have a reliable RTC clock time,
+     * we retain the canonical (delay, deadline) scheduling tuple read out of the persistent
+     * store in UTC so that we can fix up the job's scheduling criteria once we get a good
+     * wall-clock time.  If we have to persist the job again before the clock has been updated,
+     * we record these times again rather than calculating based on the earliest/latest elapsed
+     * time base figures.
+     *
+     * 'first' is the earliest/delay time, and 'second' is the latest/deadline time.
+     */
+    private Pair<Long, Long> mPersistedUtcTimes;
+
+    /**
+     * For use only by ContentObserverController: state it is maintaining about content URIs
+     * being observed.
+     */
+    ContentObserverController.JobInstance contentObserverJobInstance;
+
+    private long mTotalNetworkDownloadBytes = JobInfo.NETWORK_BYTES_UNKNOWN;
+    private long mTotalNetworkUploadBytes = JobInfo.NETWORK_BYTES_UNKNOWN;
+
+    /////// Booleans that track if a job is ready to run. They should be updated whenever dependent
+    /////// states change.
+
+    /**
+     * The deadline for the job has passed. This is only good for non-periodic jobs. A periodic job
+     * should only run if its constraints are satisfied.
+     * Computed as: NOT periodic AND has deadline constraint AND deadline constraint satisfied.
+     */
+    private boolean mReadyDeadlineSatisfied;
+
+    /**
+     * The device isn't Dozing or this job will be in the foreground. This implicit constraint must
+     * be satisfied.
+     */
+    private boolean mReadyNotDozing;
+
+    /**
+     * The job is not restricted from running in the background (due to Battery Saver). This
+     * implicit constraint must be satisfied.
+     */
+    private boolean mReadyNotRestrictedInBg;
+
+    /** The job is within its quota based on its standby bucket. */
+    private boolean mReadyWithinQuota;
+
+    /** Provide a handle to the service that this job will be run on. */
+    public int getServiceToken() {
+        return callingUid;
+    }
+
+    /**
+     * Core constructor for JobStatus instances.  All other ctors funnel down to this one.
+     *
+     * @param job The actual requested parameters for the job
+     * @param callingUid Identity of the app that is scheduling the job.  This may not be the
+     *     app in which the job is implemented; such as with sync jobs.
+     * @param sourcePackageName The package name of the app in which the job will run.
+     * @param sourceUserId The user in which the job will run
+     * @param standbyBucket The standby bucket that the source package is currently assigned to,
+     *     cached here for speed of handling during runnability evaluations (and updated when bucket
+     *     assignments are changed)
+     * @param heartbeat Timestamp of when the job was created, in the standby-related
+     *     timebase.
+     * @param tag A string associated with the job for debugging/logging purposes.
+     * @param numFailures Count of how many times this job has requested a reschedule because
+     *     its work was not yet finished.
+     * @param earliestRunTimeElapsedMillis Milestone: earliest point in time at which the job
+     *     is to be considered runnable
+     * @param latestRunTimeElapsedMillis Milestone: point in time at which the job will be
+     *     considered overdue
+     * @param lastSuccessfulRunTime When did we last run this job to completion?
+     * @param lastFailedRunTime When did we last run this job only to have it stop incomplete?
+     * @param internalFlags Non-API property flags about this job
+     */
+    private JobStatus(JobInfo job, int callingUid, String sourcePackageName,
+            int sourceUserId, int standbyBucket, long heartbeat, String tag, int numFailures,
+            long earliestRunTimeElapsedMillis, long latestRunTimeElapsedMillis,
+            long lastSuccessfulRunTime, long lastFailedRunTime, int internalFlags) {
+        this.job = job;
+        this.callingUid = callingUid;
+        this.standbyBucket = standbyBucket;
+        this.baseHeartbeat = heartbeat;
+
+        int tempSourceUid = -1;
+        if (sourceUserId != -1 && sourcePackageName != null) {
+            try {
+                tempSourceUid = AppGlobals.getPackageManager().getPackageUid(sourcePackageName, 0,
+                        sourceUserId);
+            } catch (RemoteException ex) {
+                // Can't happen, PackageManager runs in the same process.
+            }
+        }
+        if (tempSourceUid == -1) {
+            this.sourceUid = callingUid;
+            this.sourceUserId = UserHandle.getUserId(callingUid);
+            this.sourcePackageName = job.getService().getPackageName();
+            this.sourceTag = null;
+        } else {
+            this.sourceUid = tempSourceUid;
+            this.sourceUserId = sourceUserId;
+            this.sourcePackageName = sourcePackageName;
+            this.sourceTag = tag;
+        }
+
+        this.batteryName = this.sourceTag != null
+                ? this.sourceTag + ":" + job.getService().getPackageName()
+                : job.getService().flattenToShortString();
+        this.tag = "*job*/" + this.batteryName;
+
+        this.earliestRunTimeElapsedMillis = earliestRunTimeElapsedMillis;
+        this.latestRunTimeElapsedMillis = latestRunTimeElapsedMillis;
+        this.mOriginalLatestRunTimeElapsedMillis = latestRunTimeElapsedMillis;
+        this.numFailures = numFailures;
+
+        int requiredConstraints = job.getConstraintFlags();
+        if (job.getRequiredNetwork() != null) {
+            requiredConstraints |= CONSTRAINT_CONNECTIVITY;
+        }
+        if (earliestRunTimeElapsedMillis != NO_EARLIEST_RUNTIME) {
+            requiredConstraints |= CONSTRAINT_TIMING_DELAY;
+        }
+        if (latestRunTimeElapsedMillis != NO_LATEST_RUNTIME) {
+            requiredConstraints |= CONSTRAINT_DEADLINE;
+        }
+        if (job.getTriggerContentUris() != null) {
+            requiredConstraints |= CONSTRAINT_CONTENT_TRIGGER;
+        }
+        this.requiredConstraints = requiredConstraints;
+        mRequiredConstraintsOfInterest = requiredConstraints & CONSTRAINTS_OF_INTEREST;
+        mReadyNotDozing = (job.getFlags() & JobInfo.FLAG_WILL_BE_FOREGROUND) != 0;
+
+        mLastSuccessfulRunTime = lastSuccessfulRunTime;
+        mLastFailedRunTime = lastFailedRunTime;
+
+        mInternalFlags = internalFlags;
+
+        updateEstimatedNetworkBytesLocked();
+
+        if (job.getRequiredNetwork() != null) {
+            // Later, when we check if a given network satisfies the required
+            // network, we need to know the UID that is requesting it, so push
+            // our source UID into place.
+            job.getRequiredNetwork().networkCapabilities.setSingleUid(this.sourceUid);
+        }
+    }
+
+    /** Copy constructor: used specifically when cloning JobStatus objects for persistence,
+     *   so we preserve RTC window bounds if the source object has them. */
+    public JobStatus(JobStatus jobStatus) {
+        this(jobStatus.getJob(), jobStatus.getUid(),
+                jobStatus.getSourcePackageName(), jobStatus.getSourceUserId(),
+                jobStatus.getStandbyBucket(), jobStatus.getBaseHeartbeat(),
+                jobStatus.getSourceTag(), jobStatus.getNumFailures(),
+                jobStatus.getEarliestRunTime(), jobStatus.getLatestRunTimeElapsed(),
+                jobStatus.getLastSuccessfulRunTime(), jobStatus.getLastFailedRunTime(),
+                jobStatus.getInternalFlags());
+        mPersistedUtcTimes = jobStatus.mPersistedUtcTimes;
+        if (jobStatus.mPersistedUtcTimes != null) {
+            if (DEBUG) {
+                Slog.i(TAG, "Cloning job with persisted run times", new RuntimeException("here"));
+            }
+        }
+    }
+
+    /**
+     * Create a new JobStatus that was loaded from disk. We ignore the provided
+     * {@link android.app.job.JobInfo} time criteria because we can load a persisted periodic job
+     * from the {@link com.android.server.job.JobStore} and still want to respect its
+     * wallclock runtime rather than resetting it on every boot.
+     * We consider a freshly loaded job to no longer be in back-off, and the associated
+     * standby bucket is whatever the OS thinks it should be at this moment.
+     */
+    public JobStatus(JobInfo job, int callingUid, String sourcePkgName, int sourceUserId,
+            int standbyBucket, long baseHeartbeat, String sourceTag,
+            long earliestRunTimeElapsedMillis, long latestRunTimeElapsedMillis,
+            long lastSuccessfulRunTime, long lastFailedRunTime,
+            Pair<Long, Long> persistedExecutionTimesUTC,
+            int innerFlags) {
+        this(job, callingUid, sourcePkgName, sourceUserId,
+                standbyBucket, baseHeartbeat,
+                sourceTag, 0,
+                earliestRunTimeElapsedMillis, latestRunTimeElapsedMillis,
+                lastSuccessfulRunTime, lastFailedRunTime, innerFlags);
+
+        // Only during initial inflation do we record the UTC-timebase execution bounds
+        // read from the persistent store.  If we ever have to recreate the JobStatus on
+        // the fly, it means we're rescheduling the job; and this means that the calculated
+        // elapsed timebase bounds intrinsically become correct.
+        this.mPersistedUtcTimes = persistedExecutionTimesUTC;
+        if (persistedExecutionTimesUTC != null) {
+            if (DEBUG) {
+                Slog.i(TAG, "+ restored job with RTC times because of bad boot clock");
+            }
+        }
+    }
+
+    /** Create a new job to be rescheduled with the provided parameters. */
+    public JobStatus(JobStatus rescheduling, long newBaseHeartbeat,
+            long newEarliestRuntimeElapsedMillis,
+            long newLatestRuntimeElapsedMillis, int backoffAttempt,
+            long lastSuccessfulRunTime, long lastFailedRunTime) {
+        this(rescheduling.job, rescheduling.getUid(),
+                rescheduling.getSourcePackageName(), rescheduling.getSourceUserId(),
+                rescheduling.getStandbyBucket(), newBaseHeartbeat,
+                rescheduling.getSourceTag(), backoffAttempt, newEarliestRuntimeElapsedMillis,
+                newLatestRuntimeElapsedMillis,
+                lastSuccessfulRunTime, lastFailedRunTime, rescheduling.getInternalFlags());
+    }
+
+    /**
+     * Create a newly scheduled job.
+     * @param callingUid Uid of the package that scheduled this job.
+     * @param sourcePkg Package name of the app that will actually run the job.  Null indicates
+     *     that the calling package is the source.
+     * @param sourceUserId User id for whom this job is scheduled. -1 indicates this is same as the
+     *     caller.
+     */
+    public static JobStatus createFromJobInfo(JobInfo job, int callingUid, String sourcePkg,
+            int sourceUserId, String tag) {
+        final long elapsedNow = sElapsedRealtimeClock.millis();
+        final long earliestRunTimeElapsedMillis, latestRunTimeElapsedMillis;
+        if (job.isPeriodic()) {
+            // Make sure period is in the interval [min_possible_period, max_possible_period].
+            final long period = Math.max(JobInfo.getMinPeriodMillis(),
+                    Math.min(JobSchedulerService.MAX_ALLOWED_PERIOD_MS, job.getIntervalMillis()));
+            latestRunTimeElapsedMillis = elapsedNow + period;
+            earliestRunTimeElapsedMillis = latestRunTimeElapsedMillis
+                    // Make sure flex is in the interval [min_possible_flex, period].
+                    - Math.max(JobInfo.getMinFlexMillis(), Math.min(period, job.getFlexMillis()));
+        } else {
+            earliestRunTimeElapsedMillis = job.hasEarlyConstraint() ?
+                    elapsedNow + job.getMinLatencyMillis() : NO_EARLIEST_RUNTIME;
+            latestRunTimeElapsedMillis = job.hasLateConstraint() ?
+                    elapsedNow + job.getMaxExecutionDelayMillis() : NO_LATEST_RUNTIME;
+        }
+        String jobPackage = (sourcePkg != null) ? sourcePkg : job.getService().getPackageName();
+
+        int standbyBucket = JobSchedulerService.standbyBucketForPackage(jobPackage,
+                sourceUserId, elapsedNow);
+        JobSchedulerInternal js = LocalServices.getService(JobSchedulerInternal.class);
+        long currentHeartbeat = js != null
+                ? js.baseHeartbeatForApp(jobPackage, sourceUserId, standbyBucket)
+                : 0;
+        return new JobStatus(job, callingUid, sourcePkg, sourceUserId,
+                standbyBucket, currentHeartbeat, tag, 0,
+                earliestRunTimeElapsedMillis, latestRunTimeElapsedMillis,
+                0 /* lastSuccessfulRunTime */, 0 /* lastFailedRunTime */,
+                /*innerFlags=*/ 0);
+    }
+
+    public void enqueueWorkLocked(IActivityManager am, JobWorkItem work) {
+        if (pendingWork == null) {
+            pendingWork = new ArrayList<>();
+        }
+        work.setWorkId(nextPendingWorkId);
+        nextPendingWorkId++;
+        if (work.getIntent() != null
+                && GrantedUriPermissions.checkGrantFlags(work.getIntent().getFlags())) {
+            work.setGrants(GrantedUriPermissions.createFromIntent(am, work.getIntent(), sourceUid,
+                    sourcePackageName, sourceUserId, toShortString()));
+        }
+        pendingWork.add(work);
+        updateEstimatedNetworkBytesLocked();
+    }
+
+    public JobWorkItem dequeueWorkLocked() {
+        if (pendingWork != null && pendingWork.size() > 0) {
+            JobWorkItem work = pendingWork.remove(0);
+            if (work != null) {
+                if (executingWork == null) {
+                    executingWork = new ArrayList<>();
+                }
+                executingWork.add(work);
+                work.bumpDeliveryCount();
+            }
+            updateEstimatedNetworkBytesLocked();
+            return work;
+        }
+        return null;
+    }
+
+    public boolean hasWorkLocked() {
+        return (pendingWork != null && pendingWork.size() > 0) || hasExecutingWorkLocked();
+    }
+
+    public boolean hasExecutingWorkLocked() {
+        return executingWork != null && executingWork.size() > 0;
+    }
+
+    private static void ungrantWorkItem(IActivityManager am, JobWorkItem work) {
+        if (work.getGrants() != null) {
+            ((GrantedUriPermissions)work.getGrants()).revoke(am);
+        }
+    }
+
+    public boolean completeWorkLocked(IActivityManager am, int workId) {
+        if (executingWork != null) {
+            final int N = executingWork.size();
+            for (int i = 0; i < N; i++) {
+                JobWorkItem work = executingWork.get(i);
+                if (work.getWorkId() == workId) {
+                    executingWork.remove(i);
+                    ungrantWorkItem(am, work);
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static void ungrantWorkList(IActivityManager am, ArrayList<JobWorkItem> list) {
+        if (list != null) {
+            final int N = list.size();
+            for (int i = 0; i < N; i++) {
+                ungrantWorkItem(am, list.get(i));
+            }
+        }
+    }
+
+    public void stopTrackingJobLocked(IActivityManager am, JobStatus incomingJob) {
+        if (incomingJob != null) {
+            // We are replacing with a new job -- transfer the work!  We do any executing
+            // work first, since that was originally at the front of the pending work.
+            if (executingWork != null && executingWork.size() > 0) {
+                incomingJob.pendingWork = executingWork;
+            }
+            if (incomingJob.pendingWork == null) {
+                incomingJob.pendingWork = pendingWork;
+            } else if (pendingWork != null && pendingWork.size() > 0) {
+                incomingJob.pendingWork.addAll(pendingWork);
+            }
+            pendingWork = null;
+            executingWork = null;
+            incomingJob.nextPendingWorkId = nextPendingWorkId;
+            incomingJob.updateEstimatedNetworkBytesLocked();
+        } else {
+            // We are completely stopping the job...  need to clean up work.
+            ungrantWorkList(am, pendingWork);
+            pendingWork = null;
+            ungrantWorkList(am, executingWork);
+            executingWork = null;
+        }
+        updateEstimatedNetworkBytesLocked();
+    }
+
+    public void prepareLocked(IActivityManager am) {
+        if (prepared) {
+            Slog.wtf(TAG, "Already prepared: " + this);
+            return;
+        }
+        prepared = true;
+        if (DEBUG_PREPARE) {
+            unpreparedPoint = null;
+        }
+        final ClipData clip = job.getClipData();
+        if (clip != null) {
+            uriPerms = GrantedUriPermissions.createFromClip(am, clip, sourceUid, sourcePackageName,
+                    sourceUserId, job.getClipGrantFlags(), toShortString());
+        }
+    }
+
+    public void unprepareLocked(IActivityManager am) {
+        if (!prepared) {
+            Slog.wtf(TAG, "Hasn't been prepared: " + this);
+            if (DEBUG_PREPARE && unpreparedPoint != null) {
+                Slog.e(TAG, "Was already unprepared at ", unpreparedPoint);
+            }
+            return;
+        }
+        prepared = false;
+        if (DEBUG_PREPARE) {
+            unpreparedPoint = new Throwable().fillInStackTrace();
+        }
+        if (uriPerms != null) {
+            uriPerms.revoke(am);
+            uriPerms = null;
+        }
+    }
+
+    public boolean isPreparedLocked() {
+        return prepared;
+    }
+
+    public JobInfo getJob() {
+        return job;
+    }
+
+    public int getJobId() {
+        return job.getId();
+    }
+
+    public void printUniqueId(PrintWriter pw) {
+        UserHandle.formatUid(pw, callingUid);
+        pw.print("/");
+        pw.print(job.getId());
+    }
+
+    public int getNumFailures() {
+        return numFailures;
+    }
+
+    public ComponentName getServiceComponent() {
+        return job.getService();
+    }
+
+    public String getSourcePackageName() {
+        return sourcePackageName;
+    }
+
+    public int getSourceUid() {
+        return sourceUid;
+    }
+
+    public int getSourceUserId() {
+        return sourceUserId;
+    }
+
+    public int getUserId() {
+        return UserHandle.getUserId(callingUid);
+    }
+
+    public int getStandbyBucket() {
+        return standbyBucket;
+    }
+
+    public long getBaseHeartbeat() {
+        return baseHeartbeat;
+    }
+
+    public void setStandbyBucket(int newBucket) {
+        standbyBucket = newBucket;
+    }
+
+    // Called only by the standby monitoring code
+    public long getWhenStandbyDeferred() {
+        return whenStandbyDeferred;
+    }
+
+    // Called only by the standby monitoring code
+    public void setWhenStandbyDeferred(long now) {
+        whenStandbyDeferred = now;
+    }
+
+    /**
+     * Returns the first time this job was force batched, in the elapsed realtime timebase. Will be
+     * 0 if this job was never force batched.
+     */
+    public long getFirstForceBatchedTimeElapsed() {
+        return mFirstForceBatchedTimeElapsed;
+    }
+
+    public void setFirstForceBatchedTimeElapsed(long now) {
+        mFirstForceBatchedTimeElapsed = now;
+    }
+
+    public String getSourceTag() {
+        return sourceTag;
+    }
+
+    public int getUid() {
+        return callingUid;
+    }
+
+    public String getBatteryName() {
+        return batteryName;
+    }
+
+    public String getTag() {
+        return tag;
+    }
+
+    public int getPriority() {
+        return job.getPriority();
+    }
+
+    public int getFlags() {
+        return job.getFlags();
+    }
+
+    public int getInternalFlags() {
+        return mInternalFlags;
+    }
+
+    public void addInternalFlags(int flags) {
+        mInternalFlags |= flags;
+    }
+
+    public int getSatisfiedConstraintFlags() {
+        return satisfiedConstraints;
+    }
+
+    public void maybeAddForegroundExemption(Predicate<Integer> uidForegroundChecker) {
+        // Jobs with time constraints shouldn't be exempted.
+        if (job.hasEarlyConstraint() || job.hasLateConstraint()) {
+            return;
+        }
+        // Already exempted, skip the foreground check.
+        if ((mInternalFlags & INTERNAL_FLAG_HAS_FOREGROUND_EXEMPTION) != 0) {
+            return;
+        }
+        if (uidForegroundChecker.test(getSourceUid())) {
+            addInternalFlags(INTERNAL_FLAG_HAS_FOREGROUND_EXEMPTION);
+        }
+    }
+
+    private void updateEstimatedNetworkBytesLocked() {
+        mTotalNetworkDownloadBytes = job.getEstimatedNetworkDownloadBytes();
+        mTotalNetworkUploadBytes = job.getEstimatedNetworkUploadBytes();
+
+        if (pendingWork != null) {
+            for (int i = 0; i < pendingWork.size(); i++) {
+                if (mTotalNetworkDownloadBytes != JobInfo.NETWORK_BYTES_UNKNOWN) {
+                    // If any component of the job has unknown usage, we don't have a
+                    // complete picture of what data will be used, and we have to treat the
+                    // entire up/download as unknown.
+                    long downloadBytes = pendingWork.get(i).getEstimatedNetworkDownloadBytes();
+                    if (downloadBytes != JobInfo.NETWORK_BYTES_UNKNOWN) {
+                        mTotalNetworkDownloadBytes += downloadBytes;
+                    }
+                }
+                if (mTotalNetworkUploadBytes != JobInfo.NETWORK_BYTES_UNKNOWN) {
+                    // If any component of the job has unknown usage, we don't have a
+                    // complete picture of what data will be used, and we have to treat the
+                    // entire up/download as unknown.
+                    long uploadBytes = pendingWork.get(i).getEstimatedNetworkUploadBytes();
+                    if (uploadBytes != JobInfo.NETWORK_BYTES_UNKNOWN) {
+                        mTotalNetworkUploadBytes += uploadBytes;
+                    }
+                }
+            }
+        }
+    }
+
+    public long getEstimatedNetworkDownloadBytes() {
+        return mTotalNetworkDownloadBytes;
+    }
+
+    public long getEstimatedNetworkUploadBytes() {
+        return mTotalNetworkUploadBytes;
+    }
+
+    /** Does this job have any sort of networking constraint? */
+    public boolean hasConnectivityConstraint() {
+        return (requiredConstraints&CONSTRAINT_CONNECTIVITY) != 0;
+    }
+
+    public boolean hasChargingConstraint() {
+        return (requiredConstraints&CONSTRAINT_CHARGING) != 0;
+    }
+
+    public boolean hasBatteryNotLowConstraint() {
+        return (requiredConstraints&CONSTRAINT_BATTERY_NOT_LOW) != 0;
+    }
+
+    public boolean hasPowerConstraint() {
+        return (requiredConstraints&(CONSTRAINT_CHARGING|CONSTRAINT_BATTERY_NOT_LOW)) != 0;
+    }
+
+    public boolean hasStorageNotLowConstraint() {
+        return (requiredConstraints&CONSTRAINT_STORAGE_NOT_LOW) != 0;
+    }
+
+    public boolean hasTimingDelayConstraint() {
+        return (requiredConstraints&CONSTRAINT_TIMING_DELAY) != 0;
+    }
+
+    public boolean hasDeadlineConstraint() {
+        return (requiredConstraints&CONSTRAINT_DEADLINE) != 0;
+    }
+
+    public boolean hasIdleConstraint() {
+        return (requiredConstraints&CONSTRAINT_IDLE) != 0;
+    }
+
+    public boolean hasContentTriggerConstraint() {
+        return (requiredConstraints&CONSTRAINT_CONTENT_TRIGGER) != 0;
+    }
+
+    public long getTriggerContentUpdateDelay() {
+        long time = job.getTriggerContentUpdateDelay();
+        if (time < 0) {
+            return DEFAULT_TRIGGER_UPDATE_DELAY;
+        }
+        return Math.max(time, MIN_TRIGGER_UPDATE_DELAY);
+    }
+
+    public long getTriggerContentMaxDelay() {
+        long time = job.getTriggerContentMaxDelay();
+        if (time < 0) {
+            return DEFAULT_TRIGGER_MAX_DELAY;
+        }
+        return Math.max(time, MIN_TRIGGER_MAX_DELAY);
+    }
+
+    public boolean isPersisted() {
+        return job.isPersisted();
+    }
+
+    public long getEarliestRunTime() {
+        return earliestRunTimeElapsedMillis;
+    }
+
+    public long getLatestRunTimeElapsed() {
+        return latestRunTimeElapsedMillis;
+    }
+
+    public long getOriginalLatestRunTimeElapsed() {
+        return mOriginalLatestRunTimeElapsedMillis;
+    }
+
+    public void setOriginalLatestRunTimeElapsed(long latestRunTimeElapsed) {
+        mOriginalLatestRunTimeElapsedMillis = latestRunTimeElapsed;
+    }
+
+    /**
+     * Return the fractional position of "now" within the "run time" window of
+     * this job.
+     * <p>
+     * For example, if the earliest run time was 10 minutes ago, and the latest
+     * run time is 30 minutes from now, this would return 0.25.
+     * <p>
+     * If the job has no window defined, returns 1. When only an earliest or
+     * latest time is defined, it's treated as an infinitely small window at
+     * that time.
+     */
+    public float getFractionRunTime() {
+        final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+        if (earliestRunTimeElapsedMillis == 0 && latestRunTimeElapsedMillis == Long.MAX_VALUE) {
+            return 1;
+        } else if (earliestRunTimeElapsedMillis == 0) {
+            return now >= latestRunTimeElapsedMillis ? 1 : 0;
+        } else if (latestRunTimeElapsedMillis == Long.MAX_VALUE) {
+            return now >= earliestRunTimeElapsedMillis ? 1 : 0;
+        } else {
+            if (now <= earliestRunTimeElapsedMillis) {
+                return 0;
+            } else if (now >= latestRunTimeElapsedMillis) {
+                return 1;
+            } else {
+                return (float) (now - earliestRunTimeElapsedMillis)
+                        / (float) (latestRunTimeElapsedMillis - earliestRunTimeElapsedMillis);
+            }
+        }
+    }
+
+    public Pair<Long, Long> getPersistedUtcTimes() {
+        return mPersistedUtcTimes;
+    }
+
+    public void clearPersistedUtcTimes() {
+        mPersistedUtcTimes = null;
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setChargingConstraintSatisfied(boolean state) {
+        return setConstraintSatisfied(CONSTRAINT_CHARGING, state);
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setBatteryNotLowConstraintSatisfied(boolean state) {
+        return setConstraintSatisfied(CONSTRAINT_BATTERY_NOT_LOW, state);
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setStorageNotLowConstraintSatisfied(boolean state) {
+        return setConstraintSatisfied(CONSTRAINT_STORAGE_NOT_LOW, state);
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setTimingDelayConstraintSatisfied(boolean state) {
+        return setConstraintSatisfied(CONSTRAINT_TIMING_DELAY, state);
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setDeadlineConstraintSatisfied(boolean state) {
+        if (setConstraintSatisfied(CONSTRAINT_DEADLINE, state)) {
+            // The constraint was changed. Update the ready flag.
+            mReadyDeadlineSatisfied = !job.isPeriodic() && hasDeadlineConstraint() && state;
+            return true;
+        }
+        return false;
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setIdleConstraintSatisfied(boolean state) {
+        return setConstraintSatisfied(CONSTRAINT_IDLE, state);
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setConnectivityConstraintSatisfied(boolean state) {
+        return setConstraintSatisfied(CONSTRAINT_CONNECTIVITY, state);
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setContentTriggerConstraintSatisfied(boolean state) {
+        return setConstraintSatisfied(CONSTRAINT_CONTENT_TRIGGER, state);
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setDeviceNotDozingConstraintSatisfied(boolean state, boolean whitelisted) {
+        dozeWhitelisted = whitelisted;
+        if (setConstraintSatisfied(CONSTRAINT_DEVICE_NOT_DOZING, state)) {
+            // The constraint was changed. Update the ready flag.
+            mReadyNotDozing = state || (job.getFlags() & JobInfo.FLAG_WILL_BE_FOREGROUND) != 0;
+            return true;
+        }
+        return false;
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setBackgroundNotRestrictedConstraintSatisfied(boolean state) {
+        if (setConstraintSatisfied(CONSTRAINT_BACKGROUND_NOT_RESTRICTED, state)) {
+            // The constraint was changed. Update the ready flag.
+            mReadyNotRestrictedInBg = state;
+            return true;
+        }
+        return false;
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setQuotaConstraintSatisfied(boolean state) {
+        if (setConstraintSatisfied(CONSTRAINT_WITHIN_QUOTA, state)) {
+            // The constraint was changed. Update the ready flag.
+            mReadyWithinQuota = state;
+            return true;
+        }
+        return false;
+    }
+
+    /** @return true if the state was changed, false otherwise. */
+    boolean setUidActive(final boolean newActiveState) {
+        if (newActiveState != uidActive) {
+            uidActive = newActiveState;
+            return true;
+        }
+        return false; /* unchanged */
+    }
+
+    /** @return true if the constraint was changed, false otherwise. */
+    boolean setConstraintSatisfied(int constraint, boolean state) {
+        boolean old = (satisfiedConstraints&constraint) != 0;
+        if (old == state) {
+            return false;
+        }
+        satisfiedConstraints = (satisfiedConstraints&~constraint) | (state ? constraint : 0);
+        mSatisfiedConstraintsOfInterest = satisfiedConstraints & CONSTRAINTS_OF_INTEREST;
+        if (STATS_LOG_ENABLED && (STATSD_CONSTRAINTS_TO_LOG & constraint) != 0) {
+            StatsLog.write_non_chained(StatsLog.SCHEDULED_JOB_CONSTRAINT_CHANGED,
+                    sourceUid, null, getBatteryName(), getProtoConstraint(constraint),
+                    state ? StatsLog.SCHEDULED_JOB_CONSTRAINT_CHANGED__STATE__SATISFIED
+                            : StatsLog.SCHEDULED_JOB_CONSTRAINT_CHANGED__STATE__UNSATISFIED);
+        }
+        return true;
+    }
+
+    boolean isConstraintSatisfied(int constraint) {
+        return (satisfiedConstraints&constraint) != 0;
+    }
+
+    boolean clearTrackingController(int which) {
+        if ((trackingControllers&which) != 0) {
+            trackingControllers &= ~which;
+            return true;
+        }
+        return false;
+    }
+
+    void setTrackingController(int which) {
+        trackingControllers |= which;
+    }
+
+    public long getLastSuccessfulRunTime() {
+        return mLastSuccessfulRunTime;
+    }
+
+    public long getLastFailedRunTime() {
+        return mLastFailedRunTime;
+    }
+
+    /**
+     * @return Whether or not this job is ready to run, based on its requirements.
+     */
+    public boolean isReady() {
+        return isReady(mSatisfiedConstraintsOfInterest);
+    }
+
+    /**
+     * @return Whether or not this job would be ready to run if it had the specified constraint
+     * granted, based on its requirements.
+     */
+    boolean wouldBeReadyWithConstraint(int constraint) {
+        boolean oldValue = false;
+        int satisfied = mSatisfiedConstraintsOfInterest;
+        switch (constraint) {
+            case CONSTRAINT_BACKGROUND_NOT_RESTRICTED:
+                oldValue = mReadyNotRestrictedInBg;
+                mReadyNotRestrictedInBg = true;
+                break;
+            case CONSTRAINT_DEADLINE:
+                oldValue = mReadyDeadlineSatisfied;
+                mReadyDeadlineSatisfied = true;
+                break;
+            case CONSTRAINT_DEVICE_NOT_DOZING:
+                oldValue = mReadyNotDozing;
+                mReadyNotDozing = true;
+                break;
+            case CONSTRAINT_WITHIN_QUOTA:
+                oldValue = mReadyWithinQuota;
+                mReadyWithinQuota = true;
+                break;
+            default:
+                satisfied |= constraint;
+                break;
+        }
+
+        boolean toReturn = isReady(satisfied);
+
+        switch (constraint) {
+            case CONSTRAINT_BACKGROUND_NOT_RESTRICTED:
+                mReadyNotRestrictedInBg = oldValue;
+                break;
+            case CONSTRAINT_DEADLINE:
+                mReadyDeadlineSatisfied = oldValue;
+                break;
+            case CONSTRAINT_DEVICE_NOT_DOZING:
+                mReadyNotDozing = oldValue;
+                break;
+            case CONSTRAINT_WITHIN_QUOTA:
+                mReadyWithinQuota = oldValue;
+                break;
+        }
+        return toReturn;
+    }
+
+    private boolean isReady(int satisfiedConstraints) {
+        // Quota constraints trumps all other constraints.
+        if (!mReadyWithinQuota) {
+            return false;
+        }
+        // Deadline constraint trumps other constraints besides quota (except for periodic jobs
+        // where deadline is an implementation detail. A periodic job should only run if its
+        // constraints are satisfied).
+        // DeviceNotDozing implicit constraint must be satisfied
+        // NotRestrictedInBackground implicit constraint must be satisfied
+        return mReadyNotDozing && mReadyNotRestrictedInBg && (mReadyDeadlineSatisfied
+                || isConstraintsSatisfied(satisfiedConstraints));
+    }
+
+    static final int CONSTRAINTS_OF_INTEREST = CONSTRAINT_CHARGING | CONSTRAINT_BATTERY_NOT_LOW
+            | CONSTRAINT_STORAGE_NOT_LOW | CONSTRAINT_TIMING_DELAY | CONSTRAINT_CONNECTIVITY
+            | CONSTRAINT_IDLE | CONSTRAINT_CONTENT_TRIGGER;
+
+    // Soft override covers all non-"functional" constraints
+    static final int SOFT_OVERRIDE_CONSTRAINTS =
+            CONSTRAINT_CHARGING | CONSTRAINT_BATTERY_NOT_LOW | CONSTRAINT_STORAGE_NOT_LOW
+                    | CONSTRAINT_TIMING_DELAY | CONSTRAINT_IDLE;
+
+    /**
+     * @return Whether the constraints set on this job are satisfied.
+     */
+    public boolean isConstraintsSatisfied() {
+        return isConstraintsSatisfied(mSatisfiedConstraintsOfInterest);
+    }
+
+    private boolean isConstraintsSatisfied(int satisfiedConstraints) {
+        if (overrideState == OVERRIDE_FULL) {
+            // force override: the job is always runnable
+            return true;
+        }
+
+        int sat = satisfiedConstraints;
+        if (overrideState == OVERRIDE_SOFT) {
+            // override: pretend all 'soft' requirements are satisfied
+            sat |= (requiredConstraints & SOFT_OVERRIDE_CONSTRAINTS);
+        }
+
+        return (sat & mRequiredConstraintsOfInterest) == mRequiredConstraintsOfInterest;
+    }
+
+    public boolean matches(int uid, int jobId) {
+        return this.job.getId() == jobId && this.callingUid == uid;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder(128);
+        sb.append("JobStatus{");
+        sb.append(Integer.toHexString(System.identityHashCode(this)));
+        sb.append(" #");
+        UserHandle.formatUid(sb, callingUid);
+        sb.append("/");
+        sb.append(job.getId());
+        sb.append(' ');
+        sb.append(batteryName);
+        sb.append(" u=");
+        sb.append(getUserId());
+        sb.append(" s=");
+        sb.append(getSourceUid());
+        if (earliestRunTimeElapsedMillis != NO_EARLIEST_RUNTIME
+                || latestRunTimeElapsedMillis != NO_LATEST_RUNTIME) {
+            long now = sElapsedRealtimeClock.millis();
+            sb.append(" TIME=");
+            formatRunTime(sb, earliestRunTimeElapsedMillis, NO_EARLIEST_RUNTIME, now);
+            sb.append(":");
+            formatRunTime(sb, latestRunTimeElapsedMillis, NO_LATEST_RUNTIME, now);
+        }
+        if (job.getRequiredNetwork() != null) {
+            sb.append(" NET");
+        }
+        if (job.isRequireCharging()) {
+            sb.append(" CHARGING");
+        }
+        if (job.isRequireBatteryNotLow()) {
+            sb.append(" BATNOTLOW");
+        }
+        if (job.isRequireStorageNotLow()) {
+            sb.append(" STORENOTLOW");
+        }
+        if (job.isRequireDeviceIdle()) {
+            sb.append(" IDLE");
+        }
+        if (job.isPeriodic()) {
+            sb.append(" PERIODIC");
+        }
+        if (job.isPersisted()) {
+            sb.append(" PERSISTED");
+        }
+        if ((satisfiedConstraints&CONSTRAINT_DEVICE_NOT_DOZING) == 0) {
+            sb.append(" WAIT:DEV_NOT_DOZING");
+        }
+        if (job.getTriggerContentUris() != null) {
+            sb.append(" URIS=");
+            sb.append(Arrays.toString(job.getTriggerContentUris()));
+        }
+        if (numFailures != 0) {
+            sb.append(" failures=");
+            sb.append(numFailures);
+        }
+        if (isReady()) {
+            sb.append(" READY");
+        }
+        sb.append("}");
+        return sb.toString();
+    }
+
+    private void formatRunTime(PrintWriter pw, long runtime, long  defaultValue, long now) {
+        if (runtime == defaultValue) {
+            pw.print("none");
+        } else {
+            TimeUtils.formatDuration(runtime - now, pw);
+        }
+    }
+
+    private void formatRunTime(StringBuilder sb, long runtime, long  defaultValue, long now) {
+        if (runtime == defaultValue) {
+            sb.append("none");
+        } else {
+            TimeUtils.formatDuration(runtime - now, sb);
+        }
+    }
+
+    /**
+     * Convenience function to identify a job uniquely without pulling all the data that
+     * {@link #toString()} returns.
+     */
+    public String toShortString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append(Integer.toHexString(System.identityHashCode(this)));
+        sb.append(" #");
+        UserHandle.formatUid(sb, callingUid);
+        sb.append("/");
+        sb.append(job.getId());
+        sb.append(' ');
+        sb.append(batteryName);
+        return sb.toString();
+    }
+
+    /**
+     * Convenience function to identify a job uniquely without pulling all the data that
+     * {@link #toString()} returns.
+     */
+    public String toShortStringExceptUniqueId() {
+        StringBuilder sb = new StringBuilder();
+        sb.append(Integer.toHexString(System.identityHashCode(this)));
+        sb.append(' ');
+        sb.append(batteryName);
+        return sb.toString();
+    }
+
+    /**
+     * Convenience function to dump data that identifies a job uniquely to proto. This is intended
+     * to mimic {@link #toShortString}.
+     */
+    public void writeToShortProto(ProtoOutputStream proto, long fieldId) {
+        final long token = proto.start(fieldId);
+
+        proto.write(JobStatusShortInfoProto.CALLING_UID, callingUid);
+        proto.write(JobStatusShortInfoProto.JOB_ID, job.getId());
+        proto.write(JobStatusShortInfoProto.BATTERY_NAME, batteryName);
+
+        proto.end(token);
+    }
+
+    void dumpConstraints(PrintWriter pw, int constraints) {
+        if ((constraints&CONSTRAINT_CHARGING) != 0) {
+            pw.print(" CHARGING");
+        }
+        if ((constraints& CONSTRAINT_BATTERY_NOT_LOW) != 0) {
+            pw.print(" BATTERY_NOT_LOW");
+        }
+        if ((constraints& CONSTRAINT_STORAGE_NOT_LOW) != 0) {
+            pw.print(" STORAGE_NOT_LOW");
+        }
+        if ((constraints&CONSTRAINT_TIMING_DELAY) != 0) {
+            pw.print(" TIMING_DELAY");
+        }
+        if ((constraints&CONSTRAINT_DEADLINE) != 0) {
+            pw.print(" DEADLINE");
+        }
+        if ((constraints&CONSTRAINT_IDLE) != 0) {
+            pw.print(" IDLE");
+        }
+        if ((constraints&CONSTRAINT_CONNECTIVITY) != 0) {
+            pw.print(" CONNECTIVITY");
+        }
+        if ((constraints&CONSTRAINT_CONTENT_TRIGGER) != 0) {
+            pw.print(" CONTENT_TRIGGER");
+        }
+        if ((constraints&CONSTRAINT_DEVICE_NOT_DOZING) != 0) {
+            pw.print(" DEVICE_NOT_DOZING");
+        }
+        if ((constraints&CONSTRAINT_BACKGROUND_NOT_RESTRICTED) != 0) {
+            pw.print(" BACKGROUND_NOT_RESTRICTED");
+        }
+        if ((constraints & CONSTRAINT_WITHIN_QUOTA) != 0) {
+            pw.print(" WITHIN_QUOTA");
+        }
+        if (constraints != 0) {
+            pw.print(" [0x");
+            pw.print(Integer.toHexString(constraints));
+            pw.print("]");
+        }
+    }
+
+    /** Returns a {@link JobServerProtoEnums.Constraint} enum value for the given constraint. */
+    private int getProtoConstraint(int constraint) {
+        switch (constraint) {
+            case CONSTRAINT_BACKGROUND_NOT_RESTRICTED:
+                return JobServerProtoEnums.CONSTRAINT_BACKGROUND_NOT_RESTRICTED;
+            case CONSTRAINT_BATTERY_NOT_LOW:
+                return JobServerProtoEnums.CONSTRAINT_BATTERY_NOT_LOW;
+            case CONSTRAINT_CHARGING:
+                return JobServerProtoEnums.CONSTRAINT_CHARGING;
+            case CONSTRAINT_CONNECTIVITY:
+                return JobServerProtoEnums.CONSTRAINT_CONNECTIVITY;
+            case CONSTRAINT_CONTENT_TRIGGER:
+                return JobServerProtoEnums.CONSTRAINT_CONTENT_TRIGGER;
+            case CONSTRAINT_DEADLINE:
+                return JobServerProtoEnums.CONSTRAINT_DEADLINE;
+            case CONSTRAINT_DEVICE_NOT_DOZING:
+                return JobServerProtoEnums.CONSTRAINT_DEVICE_NOT_DOZING;
+            case CONSTRAINT_IDLE:
+                return JobServerProtoEnums.CONSTRAINT_IDLE;
+            case CONSTRAINT_STORAGE_NOT_LOW:
+                return JobServerProtoEnums.CONSTRAINT_STORAGE_NOT_LOW;
+            case CONSTRAINT_TIMING_DELAY:
+                return JobServerProtoEnums.CONSTRAINT_TIMING_DELAY;
+            case CONSTRAINT_WITHIN_QUOTA:
+                return JobServerProtoEnums.CONSTRAINT_WITHIN_QUOTA;
+            default:
+                return JobServerProtoEnums.CONSTRAINT_UNKNOWN;
+        }
+    }
+
+    /** Writes constraints to the given repeating proto field. */
+    void dumpConstraints(ProtoOutputStream proto, long fieldId, int constraints) {
+        if ((constraints & CONSTRAINT_CHARGING) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_CHARGING);
+        }
+        if ((constraints & CONSTRAINT_BATTERY_NOT_LOW) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_BATTERY_NOT_LOW);
+        }
+        if ((constraints & CONSTRAINT_STORAGE_NOT_LOW) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_STORAGE_NOT_LOW);
+        }
+        if ((constraints & CONSTRAINT_TIMING_DELAY) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_TIMING_DELAY);
+        }
+        if ((constraints & CONSTRAINT_DEADLINE) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_DEADLINE);
+        }
+        if ((constraints & CONSTRAINT_IDLE) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_IDLE);
+        }
+        if ((constraints & CONSTRAINT_CONNECTIVITY) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_CONNECTIVITY);
+        }
+        if ((constraints & CONSTRAINT_CONTENT_TRIGGER) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_CONTENT_TRIGGER);
+        }
+        if ((constraints & CONSTRAINT_DEVICE_NOT_DOZING) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_DEVICE_NOT_DOZING);
+        }
+        if ((constraints & CONSTRAINT_WITHIN_QUOTA) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_WITHIN_QUOTA);
+        }
+        if ((constraints & CONSTRAINT_BACKGROUND_NOT_RESTRICTED) != 0) {
+            proto.write(fieldId, JobServerProtoEnums.CONSTRAINT_BACKGROUND_NOT_RESTRICTED);
+        }
+    }
+
+    private void dumpJobWorkItem(PrintWriter pw, String prefix, JobWorkItem work, int index) {
+        pw.print(prefix); pw.print("  #"); pw.print(index); pw.print(": #");
+        pw.print(work.getWorkId()); pw.print(" "); pw.print(work.getDeliveryCount());
+        pw.print("x "); pw.println(work.getIntent());
+        if (work.getGrants() != null) {
+            pw.print(prefix); pw.println("  URI grants:");
+            ((GrantedUriPermissions)work.getGrants()).dump(pw, prefix + "    ");
+        }
+    }
+
+    private void dumpJobWorkItem(ProtoOutputStream proto, long fieldId, JobWorkItem work) {
+        final long token = proto.start(fieldId);
+
+        proto.write(JobStatusDumpProto.JobWorkItem.WORK_ID, work.getWorkId());
+        proto.write(JobStatusDumpProto.JobWorkItem.DELIVERY_COUNT, work.getDeliveryCount());
+        if (work.getIntent() != null) {
+            work.getIntent().writeToProto(proto, JobStatusDumpProto.JobWorkItem.INTENT);
+        }
+        Object grants = work.getGrants();
+        if (grants != null) {
+            ((GrantedUriPermissions) grants).dump(proto, JobStatusDumpProto.JobWorkItem.URI_GRANTS);
+        }
+
+        proto.end(token);
+    }
+
+    /**
+     * Returns a bucket name based on the normalized bucket indices, not the AppStandby constants.
+     */
+    String getBucketName() {
+        return bucketName(standbyBucket);
+    }
+
+    /**
+     * Returns a bucket name based on the normalized bucket indices, not the AppStandby constants.
+     */
+    static String bucketName(int standbyBucket) {
+        switch (standbyBucket) {
+            case 0: return "ACTIVE";
+            case 1: return "WORKING_SET";
+            case 2: return "FREQUENT";
+            case 3: return "RARE";
+            case 4: return "NEVER";
+            default:
+                return "Unknown: " + standbyBucket;
+        }
+    }
+
+    // Dumpsys infrastructure
+    public void dump(PrintWriter pw, String prefix, boolean full, long elapsedRealtimeMillis) {
+        pw.print(prefix); UserHandle.formatUid(pw, callingUid);
+        pw.print(" tag="); pw.println(tag);
+        pw.print(prefix);
+        pw.print("Source: uid="); UserHandle.formatUid(pw, getSourceUid());
+        pw.print(" user="); pw.print(getSourceUserId());
+        pw.print(" pkg="); pw.println(getSourcePackageName());
+        if (full) {
+            pw.print(prefix); pw.println("JobInfo:");
+            pw.print(prefix); pw.print("  Service: ");
+            pw.println(job.getService().flattenToShortString());
+            if (job.isPeriodic()) {
+                pw.print(prefix); pw.print("  PERIODIC: interval=");
+                TimeUtils.formatDuration(job.getIntervalMillis(), pw);
+                pw.print(" flex="); TimeUtils.formatDuration(job.getFlexMillis(), pw);
+                pw.println();
+            }
+            if (job.isPersisted()) {
+                pw.print(prefix); pw.println("  PERSISTED");
+            }
+            if (job.getPriority() != 0) {
+                pw.print(prefix); pw.print("  Priority: ");
+                pw.println(JobInfo.getPriorityString(job.getPriority()));
+            }
+            if (job.getFlags() != 0) {
+                pw.print(prefix); pw.print("  Flags: ");
+                pw.println(Integer.toHexString(job.getFlags()));
+            }
+            if (getInternalFlags() != 0) {
+                pw.print(prefix); pw.print("  Internal flags: ");
+                pw.print(Integer.toHexString(getInternalFlags()));
+
+                if ((getInternalFlags()&INTERNAL_FLAG_HAS_FOREGROUND_EXEMPTION) != 0) {
+                    pw.print(" HAS_FOREGROUND_EXEMPTION");
+                }
+                pw.println();
+            }
+            pw.print(prefix); pw.print("  Requires: charging=");
+            pw.print(job.isRequireCharging()); pw.print(" batteryNotLow=");
+            pw.print(job.isRequireBatteryNotLow()); pw.print(" deviceIdle=");
+            pw.println(job.isRequireDeviceIdle());
+            if (job.getTriggerContentUris() != null) {
+                pw.print(prefix); pw.println("  Trigger content URIs:");
+                for (int i = 0; i < job.getTriggerContentUris().length; i++) {
+                    JobInfo.TriggerContentUri trig = job.getTriggerContentUris()[i];
+                    pw.print(prefix); pw.print("    ");
+                    pw.print(Integer.toHexString(trig.getFlags()));
+                    pw.print(' '); pw.println(trig.getUri());
+                }
+                if (job.getTriggerContentUpdateDelay() >= 0) {
+                    pw.print(prefix); pw.print("  Trigger update delay: ");
+                    TimeUtils.formatDuration(job.getTriggerContentUpdateDelay(), pw);
+                    pw.println();
+                }
+                if (job.getTriggerContentMaxDelay() >= 0) {
+                    pw.print(prefix); pw.print("  Trigger max delay: ");
+                    TimeUtils.formatDuration(job.getTriggerContentMaxDelay(), pw);
+                    pw.println();
+                }
+            }
+            if (job.getExtras() != null && !job.getExtras().maybeIsEmpty()) {
+                pw.print(prefix); pw.print("  Extras: ");
+                pw.println(job.getExtras().toShortString());
+            }
+            if (job.getTransientExtras() != null && !job.getTransientExtras().maybeIsEmpty()) {
+                pw.print(prefix); pw.print("  Transient extras: ");
+                pw.println(job.getTransientExtras().toShortString());
+            }
+            if (job.getClipData() != null) {
+                pw.print(prefix); pw.print("  Clip data: ");
+                StringBuilder b = new StringBuilder(128);
+                job.getClipData().toShortString(b);
+                pw.println(b);
+            }
+            if (uriPerms != null) {
+                pw.print(prefix); pw.println("  Granted URI permissions:");
+                uriPerms.dump(pw, prefix + "  ");
+            }
+            if (job.getRequiredNetwork() != null) {
+                pw.print(prefix); pw.print("  Network type: ");
+                pw.println(job.getRequiredNetwork());
+            }
+            if (mTotalNetworkDownloadBytes != JobInfo.NETWORK_BYTES_UNKNOWN) {
+                pw.print(prefix); pw.print("  Network download bytes: ");
+                pw.println(mTotalNetworkDownloadBytes);
+            }
+            if (mTotalNetworkUploadBytes != JobInfo.NETWORK_BYTES_UNKNOWN) {
+                pw.print(prefix); pw.print("  Network upload bytes: ");
+                pw.println(mTotalNetworkUploadBytes);
+            }
+            if (job.getMinLatencyMillis() != 0) {
+                pw.print(prefix); pw.print("  Minimum latency: ");
+                TimeUtils.formatDuration(job.getMinLatencyMillis(), pw);
+                pw.println();
+            }
+            if (job.getMaxExecutionDelayMillis() != 0) {
+                pw.print(prefix); pw.print("  Max execution delay: ");
+                TimeUtils.formatDuration(job.getMaxExecutionDelayMillis(), pw);
+                pw.println();
+            }
+            pw.print(prefix); pw.print("  Backoff: policy="); pw.print(job.getBackoffPolicy());
+            pw.print(" initial="); TimeUtils.formatDuration(job.getInitialBackoffMillis(), pw);
+            pw.println();
+            if (job.hasEarlyConstraint()) {
+                pw.print(prefix); pw.println("  Has early constraint");
+            }
+            if (job.hasLateConstraint()) {
+                pw.print(prefix); pw.println("  Has late constraint");
+            }
+        }
+        pw.print(prefix); pw.print("Required constraints:");
+        dumpConstraints(pw, requiredConstraints);
+        pw.println();
+        if (full) {
+            pw.print(prefix); pw.print("Satisfied constraints:");
+            dumpConstraints(pw, satisfiedConstraints);
+            pw.println();
+            pw.print(prefix); pw.print("Unsatisfied constraints:");
+            dumpConstraints(pw,
+                    ((requiredConstraints | CONSTRAINT_WITHIN_QUOTA) & ~satisfiedConstraints));
+            pw.println();
+            if (dozeWhitelisted) {
+                pw.print(prefix); pw.println("Doze whitelisted: true");
+            }
+            if (uidActive) {
+                pw.print(prefix); pw.println("Uid: active");
+            }
+            if (job.isExemptedFromAppStandby()) {
+                pw.print(prefix); pw.println("Is exempted from app standby");
+            }
+        }
+        if (trackingControllers != 0) {
+            pw.print(prefix); pw.print("Tracking:");
+            if ((trackingControllers&TRACKING_BATTERY) != 0) pw.print(" BATTERY");
+            if ((trackingControllers&TRACKING_CONNECTIVITY) != 0) pw.print(" CONNECTIVITY");
+            if ((trackingControllers&TRACKING_CONTENT) != 0) pw.print(" CONTENT");
+            if ((trackingControllers&TRACKING_IDLE) != 0) pw.print(" IDLE");
+            if ((trackingControllers&TRACKING_STORAGE) != 0) pw.print(" STORAGE");
+            if ((trackingControllers&TRACKING_TIME) != 0) pw.print(" TIME");
+            if ((trackingControllers & TRACKING_QUOTA) != 0) pw.print(" QUOTA");
+            pw.println();
+        }
+
+        pw.print(prefix); pw.println("Implicit constraints:");
+        pw.print(prefix); pw.print("  readyNotDozing: ");
+        pw.println(mReadyNotDozing);
+        pw.print(prefix); pw.print("  readyNotRestrictedInBg: ");
+        pw.println(mReadyNotRestrictedInBg);
+        if (!job.isPeriodic() && hasDeadlineConstraint()) {
+            pw.print(prefix); pw.print("  readyDeadlineSatisfied: ");
+            pw.println(mReadyDeadlineSatisfied);
+        }
+
+        if (changedAuthorities != null) {
+            pw.print(prefix); pw.println("Changed authorities:");
+            for (int i=0; i<changedAuthorities.size(); i++) {
+                pw.print(prefix); pw.print("  "); pw.println(changedAuthorities.valueAt(i));
+            }
+            if (changedUris != null) {
+                pw.print(prefix); pw.println("Changed URIs:");
+                for (int i=0; i<changedUris.size(); i++) {
+                    pw.print(prefix); pw.print("  "); pw.println(changedUris.valueAt(i));
+                }
+            }
+        }
+        if (network != null) {
+            pw.print(prefix); pw.print("Network: "); pw.println(network);
+        }
+        if (pendingWork != null && pendingWork.size() > 0) {
+            pw.print(prefix); pw.println("Pending work:");
+            for (int i = 0; i < pendingWork.size(); i++) {
+                dumpJobWorkItem(pw, prefix, pendingWork.get(i), i);
+            }
+        }
+        if (executingWork != null && executingWork.size() > 0) {
+            pw.print(prefix); pw.println("Executing work:");
+            for (int i = 0; i < executingWork.size(); i++) {
+                dumpJobWorkItem(pw, prefix, executingWork.get(i), i);
+            }
+        }
+        pw.print(prefix); pw.print("Standby bucket: ");
+        pw.println(getBucketName());
+        if (standbyBucket > 0) {
+            pw.print(prefix); pw.print("Base heartbeat: ");
+            pw.println(baseHeartbeat);
+        }
+        if (whenStandbyDeferred != 0) {
+            pw.print(prefix); pw.print("  Deferred since: ");
+            TimeUtils.formatDuration(whenStandbyDeferred, elapsedRealtimeMillis, pw);
+            pw.println();
+        }
+        if (mFirstForceBatchedTimeElapsed != 0) {
+            pw.print(prefix);
+            pw.print("  Time since first force batch attempt: ");
+            TimeUtils.formatDuration(mFirstForceBatchedTimeElapsed, elapsedRealtimeMillis, pw);
+            pw.println();
+        }
+        pw.print(prefix); pw.print("Enqueue time: ");
+        TimeUtils.formatDuration(enqueueTime, elapsedRealtimeMillis, pw);
+        pw.println();
+        pw.print(prefix); pw.print("Run time: earliest=");
+        formatRunTime(pw, earliestRunTimeElapsedMillis, NO_EARLIEST_RUNTIME, elapsedRealtimeMillis);
+        pw.print(", latest=");
+        formatRunTime(pw, latestRunTimeElapsedMillis, NO_LATEST_RUNTIME, elapsedRealtimeMillis);
+        pw.print(", original latest=");
+        formatRunTime(pw, mOriginalLatestRunTimeElapsedMillis,
+                NO_LATEST_RUNTIME, elapsedRealtimeMillis);
+        pw.println();
+        if (numFailures != 0) {
+            pw.print(prefix); pw.print("Num failures: "); pw.println(numFailures);
+        }
+        if (mLastSuccessfulRunTime != 0) {
+            pw.print(prefix); pw.print("Last successful run: ");
+            pw.println(TimeMigrationUtils.formatMillisWithFixedFormat(mLastSuccessfulRunTime));
+        }
+        if (mLastFailedRunTime != 0) {
+            pw.print(prefix); pw.print("Last failed run: ");
+            pw.println(TimeMigrationUtils.formatMillisWithFixedFormat(mLastFailedRunTime));
+        }
+    }
+
+    public void dump(ProtoOutputStream proto, long fieldId, boolean full, long elapsedRealtimeMillis) {
+        final long token = proto.start(fieldId);
+
+        proto.write(JobStatusDumpProto.CALLING_UID, callingUid);
+        proto.write(JobStatusDumpProto.TAG, tag);
+        proto.write(JobStatusDumpProto.SOURCE_UID, getSourceUid());
+        proto.write(JobStatusDumpProto.SOURCE_USER_ID, getSourceUserId());
+        proto.write(JobStatusDumpProto.SOURCE_PACKAGE_NAME, getSourcePackageName());
+        proto.write(JobStatusDumpProto.INTERNAL_FLAGS, getInternalFlags());
+
+        if (full) {
+            final long jiToken = proto.start(JobStatusDumpProto.JOB_INFO);
+
+            job.getService().writeToProto(proto, JobStatusDumpProto.JobInfo.SERVICE);
+
+            proto.write(JobStatusDumpProto.JobInfo.IS_PERIODIC, job.isPeriodic());
+            proto.write(JobStatusDumpProto.JobInfo.PERIOD_INTERVAL_MS, job.getIntervalMillis());
+            proto.write(JobStatusDumpProto.JobInfo.PERIOD_FLEX_MS, job.getFlexMillis());
+
+            proto.write(JobStatusDumpProto.JobInfo.IS_PERSISTED, job.isPersisted());
+            proto.write(JobStatusDumpProto.JobInfo.PRIORITY, job.getPriority());
+            proto.write(JobStatusDumpProto.JobInfo.FLAGS, job.getFlags());
+
+            proto.write(JobStatusDumpProto.JobInfo.REQUIRES_CHARGING, job.isRequireCharging());
+            proto.write(JobStatusDumpProto.JobInfo.REQUIRES_BATTERY_NOT_LOW, job.isRequireBatteryNotLow());
+            proto.write(JobStatusDumpProto.JobInfo.REQUIRES_DEVICE_IDLE, job.isRequireDeviceIdle());
+
+            if (job.getTriggerContentUris() != null) {
+                for (int i = 0; i < job.getTriggerContentUris().length; i++) {
+                    final long tcuToken = proto.start(JobStatusDumpProto.JobInfo.TRIGGER_CONTENT_URIS);
+                    JobInfo.TriggerContentUri trig = job.getTriggerContentUris()[i];
+
+                    proto.write(JobStatusDumpProto.JobInfo.TriggerContentUri.FLAGS, trig.getFlags());
+                    Uri u = trig.getUri();
+                    if (u != null) {
+                        proto.write(JobStatusDumpProto.JobInfo.TriggerContentUri.URI, u.toString());
+                    }
+
+                    proto.end(tcuToken);
+                }
+                if (job.getTriggerContentUpdateDelay() >= 0) {
+                    proto.write(JobStatusDumpProto.JobInfo.TRIGGER_CONTENT_UPDATE_DELAY_MS,
+                            job.getTriggerContentUpdateDelay());
+                }
+                if (job.getTriggerContentMaxDelay() >= 0) {
+                    proto.write(JobStatusDumpProto.JobInfo.TRIGGER_CONTENT_MAX_DELAY_MS,
+                            job.getTriggerContentMaxDelay());
+                }
+            }
+            if (job.getExtras() != null && !job.getExtras().maybeIsEmpty()) {
+                job.getExtras().writeToProto(proto, JobStatusDumpProto.JobInfo.EXTRAS);
+            }
+            if (job.getTransientExtras() != null && !job.getTransientExtras().maybeIsEmpty()) {
+                job.getTransientExtras().writeToProto(proto, JobStatusDumpProto.JobInfo.TRANSIENT_EXTRAS);
+            }
+            if (job.getClipData() != null) {
+                job.getClipData().writeToProto(proto, JobStatusDumpProto.JobInfo.CLIP_DATA);
+            }
+            if (uriPerms != null) {
+                uriPerms.dump(proto, JobStatusDumpProto.JobInfo.GRANTED_URI_PERMISSIONS);
+            }
+            if (job.getRequiredNetwork() != null) {
+                job.getRequiredNetwork().writeToProto(proto, JobStatusDumpProto.JobInfo.REQUIRED_NETWORK);
+            }
+            if (mTotalNetworkDownloadBytes != JobInfo.NETWORK_BYTES_UNKNOWN) {
+                proto.write(JobStatusDumpProto.JobInfo.TOTAL_NETWORK_DOWNLOAD_BYTES,
+                        mTotalNetworkDownloadBytes);
+            }
+            if (mTotalNetworkUploadBytes != JobInfo.NETWORK_BYTES_UNKNOWN) {
+                proto.write(JobStatusDumpProto.JobInfo.TOTAL_NETWORK_UPLOAD_BYTES,
+                        mTotalNetworkUploadBytes);
+            }
+            proto.write(JobStatusDumpProto.JobInfo.MIN_LATENCY_MS, job.getMinLatencyMillis());
+            proto.write(JobStatusDumpProto.JobInfo.MAX_EXECUTION_DELAY_MS, job.getMaxExecutionDelayMillis());
+
+            final long bpToken = proto.start(JobStatusDumpProto.JobInfo.BACKOFF_POLICY);
+            proto.write(JobStatusDumpProto.JobInfo.Backoff.POLICY, job.getBackoffPolicy());
+            proto.write(JobStatusDumpProto.JobInfo.Backoff.INITIAL_BACKOFF_MS,
+                    job.getInitialBackoffMillis());
+            proto.end(bpToken);
+
+            proto.write(JobStatusDumpProto.JobInfo.HAS_EARLY_CONSTRAINT, job.hasEarlyConstraint());
+            proto.write(JobStatusDumpProto.JobInfo.HAS_LATE_CONSTRAINT, job.hasLateConstraint());
+
+            proto.end(jiToken);
+        }
+
+        dumpConstraints(proto, JobStatusDumpProto.REQUIRED_CONSTRAINTS, requiredConstraints);
+        if (full) {
+            dumpConstraints(proto, JobStatusDumpProto.SATISFIED_CONSTRAINTS, satisfiedConstraints);
+            dumpConstraints(proto, JobStatusDumpProto.UNSATISFIED_CONSTRAINTS,
+                    ((requiredConstraints | CONSTRAINT_WITHIN_QUOTA) & ~satisfiedConstraints));
+            proto.write(JobStatusDumpProto.IS_DOZE_WHITELISTED, dozeWhitelisted);
+            proto.write(JobStatusDumpProto.IS_UID_ACTIVE, uidActive);
+            proto.write(JobStatusDumpProto.IS_EXEMPTED_FROM_APP_STANDBY,
+                    job.isExemptedFromAppStandby());
+        }
+
+        // Tracking controllers
+        if ((trackingControllers&TRACKING_BATTERY) != 0) {
+            proto.write(JobStatusDumpProto.TRACKING_CONTROLLERS,
+                    JobStatusDumpProto.TRACKING_BATTERY);
+        }
+        if ((trackingControllers&TRACKING_CONNECTIVITY) != 0) {
+            proto.write(JobStatusDumpProto.TRACKING_CONTROLLERS,
+                    JobStatusDumpProto.TRACKING_CONNECTIVITY);
+        }
+        if ((trackingControllers&TRACKING_CONTENT) != 0) {
+            proto.write(JobStatusDumpProto.TRACKING_CONTROLLERS,
+                    JobStatusDumpProto.TRACKING_CONTENT);
+        }
+        if ((trackingControllers&TRACKING_IDLE) != 0) {
+            proto.write(JobStatusDumpProto.TRACKING_CONTROLLERS,
+                    JobStatusDumpProto.TRACKING_IDLE);
+        }
+        if ((trackingControllers&TRACKING_STORAGE) != 0) {
+            proto.write(JobStatusDumpProto.TRACKING_CONTROLLERS,
+                    JobStatusDumpProto.TRACKING_STORAGE);
+        }
+        if ((trackingControllers&TRACKING_TIME) != 0) {
+            proto.write(JobStatusDumpProto.TRACKING_CONTROLLERS,
+                    JobStatusDumpProto.TRACKING_TIME);
+        }
+        if ((trackingControllers & TRACKING_QUOTA) != 0) {
+            proto.write(JobStatusDumpProto.TRACKING_CONTROLLERS,
+                    JobStatusDumpProto.TRACKING_QUOTA);
+        }
+
+        // Implicit constraints
+        final long icToken = proto.start(JobStatusDumpProto.IMPLICIT_CONSTRAINTS);
+        proto.write(JobStatusDumpProto.ImplicitConstraints.IS_NOT_DOZING, mReadyNotDozing);
+        proto.write(JobStatusDumpProto.ImplicitConstraints.IS_NOT_RESTRICTED_IN_BG,
+                mReadyNotRestrictedInBg);
+        proto.end(icToken);
+
+        if (changedAuthorities != null) {
+            for (int k = 0; k < changedAuthorities.size(); k++) {
+                proto.write(JobStatusDumpProto.CHANGED_AUTHORITIES, changedAuthorities.valueAt(k));
+            }
+        }
+        if (changedUris != null) {
+            for (int i = 0; i < changedUris.size(); i++) {
+                Uri u = changedUris.valueAt(i);
+                proto.write(JobStatusDumpProto.CHANGED_URIS, u.toString());
+            }
+        }
+
+        if (network != null) {
+            network.writeToProto(proto, JobStatusDumpProto.NETWORK);
+        }
+
+        if (pendingWork != null && pendingWork.size() > 0) {
+            for (int i = 0; i < pendingWork.size(); i++) {
+                dumpJobWorkItem(proto, JobStatusDumpProto.PENDING_WORK, pendingWork.get(i));
+            }
+        }
+        if (executingWork != null && executingWork.size() > 0) {
+            for (int i = 0; i < executingWork.size(); i++) {
+                dumpJobWorkItem(proto, JobStatusDumpProto.EXECUTING_WORK, executingWork.get(i));
+            }
+        }
+
+        proto.write(JobStatusDumpProto.STANDBY_BUCKET, standbyBucket);
+        proto.write(JobStatusDumpProto.ENQUEUE_DURATION_MS, elapsedRealtimeMillis - enqueueTime);
+        proto.write(JobStatusDumpProto.TIME_SINCE_FIRST_DEFERRAL_MS,
+                whenStandbyDeferred == 0 ? 0 : elapsedRealtimeMillis - whenStandbyDeferred);
+        proto.write(JobStatusDumpProto.TIME_SINCE_FIRST_FORCE_BATCH_ATTEMPT_MS,
+                mFirstForceBatchedTimeElapsed == 0
+                        ? 0 : elapsedRealtimeMillis - mFirstForceBatchedTimeElapsed);
+        if (earliestRunTimeElapsedMillis == NO_EARLIEST_RUNTIME) {
+            proto.write(JobStatusDumpProto.TIME_UNTIL_EARLIEST_RUNTIME_MS, 0);
+        } else {
+            proto.write(JobStatusDumpProto.TIME_UNTIL_EARLIEST_RUNTIME_MS,
+                    earliestRunTimeElapsedMillis - elapsedRealtimeMillis);
+        }
+        if (latestRunTimeElapsedMillis == NO_LATEST_RUNTIME) {
+            proto.write(JobStatusDumpProto.TIME_UNTIL_LATEST_RUNTIME_MS, 0);
+        } else {
+            proto.write(JobStatusDumpProto.TIME_UNTIL_LATEST_RUNTIME_MS,
+                    latestRunTimeElapsedMillis - elapsedRealtimeMillis);
+        }
+
+        proto.write(JobStatusDumpProto.NUM_FAILURES, numFailures);
+        proto.write(JobStatusDumpProto.LAST_SUCCESSFUL_RUN_TIME, mLastSuccessfulRunTime);
+        proto.write(JobStatusDumpProto.LAST_FAILED_RUN_TIME, mLastFailedRunTime);
+
+        proto.end(token);
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/QuotaController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/QuotaController.java
new file mode 100644
index 0000000..b8cfac4
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/QuotaController.java
@@ -0,0 +1,2829 @@
+/*
+ * Copyright (C) 2018 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.controllers;
+
+import static android.text.format.DateUtils.HOUR_IN_MILLIS;
+import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
+import static android.text.format.DateUtils.SECOND_IN_MILLIS;
+
+import static com.android.server.job.JobSchedulerService.ACTIVE_INDEX;
+import static com.android.server.job.JobSchedulerService.FREQUENT_INDEX;
+import static com.android.server.job.JobSchedulerService.NEVER_INDEX;
+import static com.android.server.job.JobSchedulerService.RARE_INDEX;
+import static com.android.server.job.JobSchedulerService.WORKING_INDEX;
+import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.UserIdInt;
+import android.app.ActivityManager;
+import android.app.ActivityManagerInternal;
+import android.app.AlarmManager;
+import android.app.AppGlobals;
+import android.app.IUidObserver;
+import android.app.usage.UsageStatsManagerInternal;
+import android.app.usage.UsageStatsManagerInternal.AppIdleStateChangeListener;
+import android.content.BroadcastReceiver;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.BatteryManager;
+import android.os.BatteryManagerInternal;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.RemoteException;
+import android.os.UserHandle;
+import android.provider.Settings;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.KeyValueListParser;
+import android.util.Log;
+import android.util.Slog;
+import android.util.SparseArray;
+import android.util.SparseBooleanArray;
+import android.util.SparseSetArray;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.os.BackgroundThread;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.LocalServices;
+import com.android.server.job.ConstantsProto;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.JobServiceContext;
+import com.android.server.job.StateControllerProto;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * Controller that tracks whether an app has exceeded its standby bucket quota.
+ *
+ * With initial defaults, each app in each bucket is given 10 minutes to run within its respective
+ * time window. Active jobs can run indefinitely, working set jobs can run for 10 minutes within a
+ * 2 hour window, frequent jobs get to run 10 minutes in an 8 hour window, and rare jobs get to run
+ * 10 minutes in a 24 hour window. The windows are rolling, so as soon as a job would have some
+ * quota based on its bucket, it will be eligible to run. When a job's bucket changes, its new
+ * quota is immediately applied to it.
+ *
+ * Job and session count limits are included to prevent abuse/spam. Each bucket has its own limit on
+ * the number of jobs or sessions that can run within the window. Regardless of bucket, apps will
+ * not be allowed to run more than 20 jobs within the past 10 minutes.
+ *
+ * Jobs are throttled while an app is not in a foreground state. All jobs are allowed to run
+ * freely when an app enters the foreground state and are restricted when the app leaves the
+ * foreground state. However, jobs that are started while the app is in the TOP state do not count
+ * towards any quota and are not restricted regardless of the app's state change.
+ *
+ * Jobs will not be throttled when the device is charging. The device is considered to be charging
+ * once the {@link BatteryManager#ACTION_CHARGING} intent has been broadcast.
+ *
+ * Note: all limits are enforced per bucket window unless explicitly stated otherwise.
+ * All stated values are configurable and subject to change. See {@link QcConstants} for current
+ * defaults.
+ *
+ * Test: atest com.android.server.job.controllers.QuotaControllerTest
+ */
+public final class QuotaController extends StateController {
+    private static final String TAG = "JobScheduler.Quota";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG
+            || Log.isLoggable(TAG, Log.DEBUG);
+
+    private static final String ALARM_TAG_CLEANUP = "*job.cleanup*";
+    private static final String ALARM_TAG_QUOTA_CHECK = "*job.quota_check*";
+
+    /**
+     * A sparse array of ArrayMaps, which is suitable for holding (userId, packageName)->object
+     * associations.
+     */
+    private static class UserPackageMap<T> {
+        private final SparseArray<ArrayMap<String, T>> mData = new SparseArray<>();
+
+        public void add(int userId, @NonNull String packageName, @Nullable T obj) {
+            ArrayMap<String, T> data = mData.get(userId);
+            if (data == null) {
+                data = new ArrayMap<String, T>();
+                mData.put(userId, data);
+            }
+            data.put(packageName, obj);
+        }
+
+        public void clear() {
+            for (int i = 0; i < mData.size(); ++i) {
+                mData.valueAt(i).clear();
+            }
+        }
+
+        /** Removes all the data for the user, if there was any. */
+        public void delete(int userId) {
+            mData.delete(userId);
+        }
+
+        /** Removes the data for the user and package, if there was any. */
+        public void delete(int userId, @NonNull String packageName) {
+            ArrayMap<String, T> data = mData.get(userId);
+            if (data != null) {
+                data.remove(packageName);
+            }
+        }
+
+        @Nullable
+        public T get(int userId, @NonNull String packageName) {
+            ArrayMap<String, T> data = mData.get(userId);
+            if (data != null) {
+                return data.get(packageName);
+            }
+            return null;
+        }
+
+        /** @see SparseArray#indexOfKey */
+        public int indexOfKey(int userId) {
+            return mData.indexOfKey(userId);
+        }
+
+        /** Returns the userId at the given index. */
+        public int keyAt(int index) {
+            return mData.keyAt(index);
+        }
+
+        /** Returns the package name at the given index. */
+        @NonNull
+        public String keyAt(int userIndex, int packageIndex) {
+            return mData.valueAt(userIndex).keyAt(packageIndex);
+        }
+
+        /** Returns the size of the outer (userId) array. */
+        public int numUsers() {
+            return mData.size();
+        }
+
+        public int numPackagesForUser(int userId) {
+            ArrayMap<String, T> data = mData.get(userId);
+            return data == null ? 0 : data.size();
+        }
+
+        /** Returns the value T at the given user and index. */
+        @Nullable
+        public T valueAt(int userIndex, int packageIndex) {
+            return mData.valueAt(userIndex).valueAt(packageIndex);
+        }
+
+        public void forEach(Consumer<T> consumer) {
+            for (int i = numUsers() - 1; i >= 0; --i) {
+                ArrayMap<String, T> data = mData.valueAt(i);
+                for (int j = data.size() - 1; j >= 0; --j) {
+                    consumer.accept(data.valueAt(j));
+                }
+            }
+        }
+    }
+
+    /**
+     * Standardize the output of userId-packageName combo.
+     */
+    private static String string(int userId, String packageName) {
+        return "<" + userId + ">" + packageName;
+    }
+
+    private static final class Package {
+        public final String packageName;
+        public final int userId;
+
+        Package(int userId, String packageName) {
+            this.userId = userId;
+            this.packageName = packageName;
+        }
+
+        @Override
+        public String toString() {
+            return string(userId, packageName);
+        }
+
+        public void writeToProto(ProtoOutputStream proto, long fieldId) {
+            final long token = proto.start(fieldId);
+
+            proto.write(StateControllerProto.QuotaController.Package.USER_ID, userId);
+            proto.write(StateControllerProto.QuotaController.Package.NAME, packageName);
+
+            proto.end(token);
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (obj instanceof Package) {
+                Package other = (Package) obj;
+                return userId == other.userId && Objects.equals(packageName, other.packageName);
+            } else {
+                return false;
+            }
+        }
+
+        @Override
+        public int hashCode() {
+            return packageName.hashCode() + userId;
+        }
+    }
+
+    private static int hashLong(long val) {
+        return (int) (val ^ (val >>> 32));
+    }
+
+    @VisibleForTesting
+    static class ExecutionStats {
+        /**
+         * The time after which this record should be considered invalid (out of date), in the
+         * elapsed realtime timebase.
+         */
+        public long expirationTimeElapsed;
+
+        public long windowSizeMs;
+        public int jobCountLimit;
+        public int sessionCountLimit;
+
+        /** The total amount of time the app ran in its respective bucket window size. */
+        public long executionTimeInWindowMs;
+        public int bgJobCountInWindow;
+
+        /** The total amount of time the app ran in the last {@link #MAX_PERIOD_MS}. */
+        public long executionTimeInMaxPeriodMs;
+        public int bgJobCountInMaxPeriod;
+
+        /**
+         * The number of {@link TimingSession}s within the bucket window size. This will include
+         * sessions that started before the window as long as they end within the window.
+         */
+        public int sessionCountInWindow;
+
+        /**
+         * The time after which the app will be under the bucket quota and can start running jobs
+         * again. This is only valid if
+         * {@link #executionTimeInWindowMs} >= {@link #mAllowedTimePerPeriodMs},
+         * {@link #executionTimeInMaxPeriodMs} >= {@link #mMaxExecutionTimeMs},
+         * {@link #bgJobCountInWindow} >= {@link #jobCountLimit}, or
+         * {@link #sessionCountInWindow} >= {@link #sessionCountLimit}.
+         */
+        public long inQuotaTimeElapsed;
+
+        /**
+         * The time after which {@link #jobCountInRateLimitingWindow} should be considered invalid,
+         * in the elapsed realtime timebase.
+         */
+        public long jobRateLimitExpirationTimeElapsed;
+
+        /**
+         * The number of jobs that ran in at least the last {@link #mRateLimitingWindowMs}.
+         * It may contain a few stale entries since cleanup won't happen exactly every
+         * {@link #mRateLimitingWindowMs}.
+         */
+        public int jobCountInRateLimitingWindow;
+
+        /**
+         * The time after which {@link #sessionCountInRateLimitingWindow} should be considered
+         * invalid, in the elapsed realtime timebase.
+         */
+        public long sessionRateLimitExpirationTimeElapsed;
+
+        /**
+         * The number of {@link TimingSession}s that ran in at least the last
+         * {@link #mRateLimitingWindowMs}. It may contain a few stale entries since cleanup won't
+         * happen exactly every {@link #mRateLimitingWindowMs}. This should only be considered
+         * valid before elapsed realtime has reached {@link #sessionRateLimitExpirationTimeElapsed}.
+         */
+        public int sessionCountInRateLimitingWindow;
+
+        @Override
+        public String toString() {
+            return "expirationTime=" + expirationTimeElapsed + ", "
+                    + "windowSizeMs=" + windowSizeMs + ", "
+                    + "jobCountLimit=" + jobCountLimit + ", "
+                    + "sessionCountLimit=" + sessionCountLimit + ", "
+                    + "executionTimeInWindow=" + executionTimeInWindowMs + ", "
+                    + "bgJobCountInWindow=" + bgJobCountInWindow + ", "
+                    + "executionTimeInMaxPeriod=" + executionTimeInMaxPeriodMs + ", "
+                    + "bgJobCountInMaxPeriod=" + bgJobCountInMaxPeriod + ", "
+                    + "sessionCountInWindow=" + sessionCountInWindow + ", "
+                    + "inQuotaTime=" + inQuotaTimeElapsed + ", "
+                    + "jobCountExpirationTime=" + jobRateLimitExpirationTimeElapsed + ", "
+                    + "jobCountInRateLimitingWindow=" + jobCountInRateLimitingWindow + ", "
+                    + "sessionCountExpirationTime=" + sessionRateLimitExpirationTimeElapsed + ", "
+                    + "sessionCountInRateLimitingWindow=" + sessionCountInRateLimitingWindow;
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (obj instanceof ExecutionStats) {
+                ExecutionStats other = (ExecutionStats) obj;
+                return this.expirationTimeElapsed == other.expirationTimeElapsed
+                        && this.windowSizeMs == other.windowSizeMs
+                        && this.jobCountLimit == other.jobCountLimit
+                        && this.sessionCountLimit == other.sessionCountLimit
+                        && this.executionTimeInWindowMs == other.executionTimeInWindowMs
+                        && this.bgJobCountInWindow == other.bgJobCountInWindow
+                        && this.executionTimeInMaxPeriodMs == other.executionTimeInMaxPeriodMs
+                        && this.sessionCountInWindow == other.sessionCountInWindow
+                        && this.bgJobCountInMaxPeriod == other.bgJobCountInMaxPeriod
+                        && this.inQuotaTimeElapsed == other.inQuotaTimeElapsed
+                        && this.jobRateLimitExpirationTimeElapsed
+                                == other.jobRateLimitExpirationTimeElapsed
+                        && this.jobCountInRateLimitingWindow == other.jobCountInRateLimitingWindow
+                        && this.sessionRateLimitExpirationTimeElapsed
+                                == other.sessionRateLimitExpirationTimeElapsed
+                        && this.sessionCountInRateLimitingWindow
+                                == other.sessionCountInRateLimitingWindow;
+            } else {
+                return false;
+            }
+        }
+
+        @Override
+        public int hashCode() {
+            int result = 0;
+            result = 31 * result + hashLong(expirationTimeElapsed);
+            result = 31 * result + hashLong(windowSizeMs);
+            result = 31 * result + hashLong(jobCountLimit);
+            result = 31 * result + hashLong(sessionCountLimit);
+            result = 31 * result + hashLong(executionTimeInWindowMs);
+            result = 31 * result + bgJobCountInWindow;
+            result = 31 * result + hashLong(executionTimeInMaxPeriodMs);
+            result = 31 * result + bgJobCountInMaxPeriod;
+            result = 31 * result + sessionCountInWindow;
+            result = 31 * result + hashLong(inQuotaTimeElapsed);
+            result = 31 * result + hashLong(jobRateLimitExpirationTimeElapsed);
+            result = 31 * result + jobCountInRateLimitingWindow;
+            result = 31 * result + hashLong(sessionRateLimitExpirationTimeElapsed);
+            result = 31 * result + sessionCountInRateLimitingWindow;
+            return result;
+        }
+    }
+
+    /** List of all tracked jobs keyed by source package-userId combo. */
+    private final UserPackageMap<ArraySet<JobStatus>> mTrackedJobs = new UserPackageMap<>();
+
+    /** Timer for each package-userId combo. */
+    private final UserPackageMap<Timer> mPkgTimers = new UserPackageMap<>();
+
+    /** List of all timing sessions for a package-userId combo, in chronological order. */
+    private final UserPackageMap<List<TimingSession>> mTimingSessions = new UserPackageMap<>();
+
+    /**
+     * List of alarm listeners for each package that listen for when each package comes back within
+     * quota.
+     */
+    private final UserPackageMap<QcAlarmListener> mInQuotaAlarmListeners = new UserPackageMap<>();
+
+    /** Cached calculation results for each app, with the standby buckets as the array indices. */
+    private final UserPackageMap<ExecutionStats[]> mExecutionStatsCache = new UserPackageMap<>();
+
+    /** List of UIDs currently in the foreground. */
+    private final SparseBooleanArray mForegroundUids = new SparseBooleanArray();
+
+    /** Cached mapping of UIDs (for all users) to a list of packages in the UID. */
+    private final SparseSetArray<String> mUidToPackageCache = new SparseSetArray<>();
+
+    /**
+     * List of jobs that started while the UID was in the TOP state. There will be no more than
+     * 16 ({@link JobSchedulerService#MAX_JOB_CONTEXTS_COUNT}) running at once, so an ArraySet is
+     * fine.
+     */
+    private final ArraySet<JobStatus> mTopStartedJobs = new ArraySet<>();
+
+    private final ActivityManagerInternal mActivityManagerInternal;
+    private final AlarmManager mAlarmManager;
+    private final ChargingTracker mChargeTracker;
+    private final Handler mHandler;
+    private final QcConstants mQcConstants;
+
+    private volatile boolean mInParole;
+
+    /**
+     * If the QuotaController should throttle apps based on their standby bucket and job activity.
+     * If false, all jobs will have their CONSTRAINT_WITHIN_QUOTA bit set to true immediately and
+     * indefinitely.
+     */
+    private boolean mShouldThrottle;
+
+    /** How much time each app will have to run jobs within their standby bucket window. */
+    private long mAllowedTimePerPeriodMs = QcConstants.DEFAULT_ALLOWED_TIME_PER_PERIOD_MS;
+
+    /**
+     * The maximum amount of time an app can have its jobs running within a {@link #MAX_PERIOD_MS}
+     * window.
+     */
+    private long mMaxExecutionTimeMs = QcConstants.DEFAULT_MAX_EXECUTION_TIME_MS;
+
+    /**
+     * How much time the app should have before transitioning from out-of-quota to in-quota.
+     * This should not affect processing if the app is already in-quota.
+     */
+    private long mQuotaBufferMs = QcConstants.DEFAULT_IN_QUOTA_BUFFER_MS;
+
+    /**
+     * {@link #mAllowedTimePerPeriodMs} - {@link #mQuotaBufferMs}. This can be used to determine
+     * when an app will have enough quota to transition from out-of-quota to in-quota.
+     */
+    private long mAllowedTimeIntoQuotaMs = mAllowedTimePerPeriodMs - mQuotaBufferMs;
+
+    /**
+     * {@link #mMaxExecutionTimeMs} - {@link #mQuotaBufferMs}. This can be used to determine when an
+     * app will have enough quota to transition from out-of-quota to in-quota.
+     */
+    private long mMaxExecutionTimeIntoQuotaMs = mMaxExecutionTimeMs - mQuotaBufferMs;
+
+    /** The period of time used to rate limit recently run jobs. */
+    private long mRateLimitingWindowMs = QcConstants.DEFAULT_RATE_LIMITING_WINDOW_MS;
+
+    /** The maximum number of jobs that can run within the past {@link #mRateLimitingWindowMs}. */
+    private int mMaxJobCountPerRateLimitingWindow =
+            QcConstants.DEFAULT_MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW;
+
+    /**
+     * The maximum number of {@link TimingSession}s that can run within the past {@link
+     * #mRateLimitingWindowMs}.
+     */
+    private int mMaxSessionCountPerRateLimitingWindow =
+            QcConstants.DEFAULT_MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW;
+
+    private long mNextCleanupTimeElapsed = 0;
+    private final AlarmManager.OnAlarmListener mSessionCleanupAlarmListener =
+            new AlarmManager.OnAlarmListener() {
+                @Override
+                public void onAlarm() {
+                    mHandler.obtainMessage(MSG_CLEAN_UP_SESSIONS).sendToTarget();
+                }
+            };
+
+    private final IUidObserver mUidObserver = new IUidObserver.Stub() {
+        @Override
+        public void onUidStateChanged(int uid, int procState, long procStateSeq) {
+            mHandler.obtainMessage(MSG_UID_PROCESS_STATE_CHANGED, uid, procState).sendToTarget();
+        }
+
+        @Override
+        public void onUidGone(int uid, boolean disabled) {
+        }
+
+        @Override
+        public void onUidActive(int uid) {
+        }
+
+        @Override
+        public void onUidIdle(int uid, boolean disabled) {
+        }
+
+        @Override
+        public void onUidCachedChanged(int uid, boolean cached) {
+        }
+    };
+
+    private final BroadcastReceiver mPackageAddedReceiver = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            if (intent == null) {
+                return;
+            }
+            if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) {
+                return;
+            }
+            final int uid = intent.getIntExtra(Intent.EXTRA_UID, -1);
+            synchronized (mLock) {
+                mUidToPackageCache.remove(uid);
+            }
+        }
+    };
+
+    /**
+     * The rolling window size for each standby bucket. Within each window, an app will have 10
+     * minutes to run its jobs.
+     */
+    private final long[] mBucketPeriodsMs = new long[]{
+            QcConstants.DEFAULT_WINDOW_SIZE_ACTIVE_MS,
+            QcConstants.DEFAULT_WINDOW_SIZE_WORKING_MS,
+            QcConstants.DEFAULT_WINDOW_SIZE_FREQUENT_MS,
+            QcConstants.DEFAULT_WINDOW_SIZE_RARE_MS
+    };
+
+    /** The maximum period any bucket can have. */
+    private static final long MAX_PERIOD_MS = 24 * 60 * MINUTE_IN_MILLIS;
+
+    /**
+     * The maximum number of jobs based on its standby bucket. For each max value count in the
+     * array, the app will not be allowed to run more than that many number of jobs within the
+     * latest time interval of its rolling window size.
+     *
+     * @see #mBucketPeriodsMs
+     */
+    private final int[] mMaxBucketJobCounts = new int[]{
+            QcConstants.DEFAULT_MAX_JOB_COUNT_ACTIVE,
+            QcConstants.DEFAULT_MAX_JOB_COUNT_WORKING,
+            QcConstants.DEFAULT_MAX_JOB_COUNT_FREQUENT,
+            QcConstants.DEFAULT_MAX_JOB_COUNT_RARE
+    };
+
+    /**
+     * The maximum number of {@link TimingSession}s based on its standby bucket. For each max value
+     * count in the array, the app will not be allowed to have more than that many number of
+     * {@link TimingSession}s within the latest time interval of its rolling window size.
+     *
+     * @see #mBucketPeriodsMs
+     */
+    private final int[] mMaxBucketSessionCounts = new int[]{
+            QcConstants.DEFAULT_MAX_SESSION_COUNT_ACTIVE,
+            QcConstants.DEFAULT_MAX_SESSION_COUNT_WORKING,
+            QcConstants.DEFAULT_MAX_SESSION_COUNT_FREQUENT,
+            QcConstants.DEFAULT_MAX_SESSION_COUNT_RARE
+    };
+
+    /**
+     * Treat two distinct {@link TimingSession}s as the same if they start and end within this
+     * amount of time of each other.
+     */
+    private long mTimingSessionCoalescingDurationMs =
+            QcConstants.DEFAULT_TIMING_SESSION_COALESCING_DURATION_MS;
+
+    /** An app has reached its quota. The message should contain a {@link Package} object. */
+    private static final int MSG_REACHED_QUOTA = 0;
+    /** Drop any old timing sessions. */
+    private static final int MSG_CLEAN_UP_SESSIONS = 1;
+    /** Check if a package is now within its quota. */
+    private static final int MSG_CHECK_PACKAGE = 2;
+    /** Process state for a UID has changed. */
+    private static final int MSG_UID_PROCESS_STATE_CHANGED = 3;
+
+    public QuotaController(JobSchedulerService service) {
+        super(service);
+        mHandler = new QcHandler(mContext.getMainLooper());
+        mChargeTracker = new ChargingTracker();
+        mChargeTracker.startTracking();
+        mActivityManagerInternal = LocalServices.getService(ActivityManagerInternal.class);
+        mAlarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
+        mQcConstants = new QcConstants(mHandler);
+
+        final IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED);
+        mContext.registerReceiverAsUser(mPackageAddedReceiver, UserHandle.ALL, filter, null, null);
+
+        // Set up the app standby bucketing tracker
+        UsageStatsManagerInternal usageStats = LocalServices.getService(
+                UsageStatsManagerInternal.class);
+        usageStats.addAppIdleStateChangeListener(new StandbyTracker());
+
+        try {
+            ActivityManager.getService().registerUidObserver(mUidObserver,
+                    ActivityManager.UID_OBSERVER_PROCSTATE,
+                    ActivityManager.PROCESS_STATE_FOREGROUND_SERVICE, null);
+        } catch (RemoteException e) {
+            // ignored; both services live in system_server
+        }
+
+        mShouldThrottle = !mConstants.USE_HEARTBEATS;
+    }
+
+    @Override
+    public void onSystemServicesReady() {
+        mQcConstants.start(mContext.getContentResolver());
+    }
+
+    @Override
+    public void maybeStartTrackingJobLocked(JobStatus jobStatus, JobStatus lastJob) {
+        final int userId = jobStatus.getSourceUserId();
+        final String pkgName = jobStatus.getSourcePackageName();
+        // Still need to track jobs even if mShouldThrottle is false in case it's set to true at
+        // some point.
+        ArraySet<JobStatus> jobs = mTrackedJobs.get(userId, pkgName);
+        if (jobs == null) {
+            jobs = new ArraySet<>();
+            mTrackedJobs.add(userId, pkgName, jobs);
+        }
+        jobs.add(jobStatus);
+        jobStatus.setTrackingController(JobStatus.TRACKING_QUOTA);
+        if (mShouldThrottle) {
+            final boolean isWithinQuota = isWithinQuotaLocked(jobStatus);
+            setConstraintSatisfied(jobStatus, isWithinQuota);
+            if (!isWithinQuota) {
+                maybeScheduleStartAlarmLocked(userId, pkgName,
+                        getEffectiveStandbyBucket(jobStatus));
+            }
+        } else {
+            // QuotaController isn't throttling, so always set to true.
+            jobStatus.setQuotaConstraintSatisfied(true);
+        }
+    }
+
+    @Override
+    public void prepareForExecutionLocked(JobStatus jobStatus) {
+        if (DEBUG) {
+            Slog.d(TAG, "Prepping for " + jobStatus.toShortString());
+        }
+
+        final int uid = jobStatus.getSourceUid();
+        if (mActivityManagerInternal.getUidProcessState(uid) <= ActivityManager.PROCESS_STATE_TOP) {
+            if (DEBUG) {
+                Slog.d(TAG, jobStatus.toShortString() + " is top started job");
+            }
+            mTopStartedJobs.add(jobStatus);
+            // Top jobs won't count towards quota so there's no need to involve the Timer.
+            return;
+        }
+
+        final int userId = jobStatus.getSourceUserId();
+        final String packageName = jobStatus.getSourcePackageName();
+        Timer timer = mPkgTimers.get(userId, packageName);
+        if (timer == null) {
+            timer = new Timer(uid, userId, packageName);
+            mPkgTimers.add(userId, packageName, timer);
+        }
+        timer.startTrackingJobLocked(jobStatus);
+    }
+
+    @Override
+    public void maybeStopTrackingJobLocked(JobStatus jobStatus, JobStatus incomingJob,
+            boolean forUpdate) {
+        if (jobStatus.clearTrackingController(JobStatus.TRACKING_QUOTA)) {
+            Timer timer = mPkgTimers.get(jobStatus.getSourceUserId(),
+                    jobStatus.getSourcePackageName());
+            if (timer != null) {
+                timer.stopTrackingJob(jobStatus);
+            }
+            ArraySet<JobStatus> jobs = mTrackedJobs.get(jobStatus.getSourceUserId(),
+                    jobStatus.getSourcePackageName());
+            if (jobs != null) {
+                jobs.remove(jobStatus);
+            }
+            mTopStartedJobs.remove(jobStatus);
+        }
+    }
+
+    @Override
+    public void onConstantsUpdatedLocked() {
+        if (mShouldThrottle == mConstants.USE_HEARTBEATS) {
+            mShouldThrottle = !mConstants.USE_HEARTBEATS;
+
+            // Update job bookkeeping out of band.
+            BackgroundThread.getHandler().post(() -> {
+                synchronized (mLock) {
+                    maybeUpdateAllConstraintsLocked();
+                }
+            });
+        }
+    }
+
+    @Override
+    public void onAppRemovedLocked(String packageName, int uid) {
+        if (packageName == null) {
+            Slog.wtf(TAG, "Told app removed but given null package name.");
+            return;
+        }
+        final int userId = UserHandle.getUserId(uid);
+        mTrackedJobs.delete(userId, packageName);
+        Timer timer = mPkgTimers.get(userId, packageName);
+        if (timer != null) {
+            if (timer.isActive()) {
+                Slog.wtf(TAG, "onAppRemovedLocked called before Timer turned off.");
+                timer.dropEverythingLocked();
+            }
+            mPkgTimers.delete(userId, packageName);
+        }
+        mTimingSessions.delete(userId, packageName);
+        QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, packageName);
+        if (alarmListener != null) {
+            mAlarmManager.cancel(alarmListener);
+            mInQuotaAlarmListeners.delete(userId, packageName);
+        }
+        mExecutionStatsCache.delete(userId, packageName);
+        mForegroundUids.delete(uid);
+        mUidToPackageCache.remove(uid);
+    }
+
+    @Override
+    public void onUserRemovedLocked(int userId) {
+        mTrackedJobs.delete(userId);
+        mPkgTimers.delete(userId);
+        mTimingSessions.delete(userId);
+        mInQuotaAlarmListeners.delete(userId);
+        mExecutionStatsCache.delete(userId);
+        mUidToPackageCache.clear();
+    }
+
+    private boolean isUidInForeground(int uid) {
+        if (UserHandle.isCore(uid)) {
+            return true;
+        }
+        synchronized (mLock) {
+            return mForegroundUids.get(uid);
+        }
+    }
+
+    /** @return true if the job was started while the app was in the TOP state. */
+    private boolean isTopStartedJobLocked(@NonNull final JobStatus jobStatus) {
+        return mTopStartedJobs.contains(jobStatus);
+    }
+
+    /** Returns the maximum amount of time this job could run for. */
+    public long getMaxJobExecutionTimeMsLocked(@NonNull final JobStatus jobStatus) {
+        // If quota is currently "free", then the job can run for the full amount of time.
+        if (mChargeTracker.isCharging()
+                || mInParole
+                || isTopStartedJobLocked(jobStatus)
+                || isUidInForeground(jobStatus.getSourceUid())) {
+            return JobServiceContext.EXECUTING_TIMESLICE_MILLIS;
+        }
+        return getRemainingExecutionTimeLocked(jobStatus);
+    }
+
+    /**
+     * Returns an appropriate standby bucket for the job, taking into account any standby
+     * exemptions.
+     */
+    private int getEffectiveStandbyBucket(@NonNull final JobStatus jobStatus) {
+        if (jobStatus.uidActive || jobStatus.getJob().isExemptedFromAppStandby()) {
+            // Treat these cases as if they're in the ACTIVE bucket so that they get throttled
+            // like other ACTIVE apps.
+            return ACTIVE_INDEX;
+        }
+        return jobStatus.getStandbyBucket();
+    }
+
+    @VisibleForTesting
+    boolean isWithinQuotaLocked(@NonNull final JobStatus jobStatus) {
+        final int standbyBucket = getEffectiveStandbyBucket(jobStatus);
+        // A job is within quota if one of the following is true:
+        //   1. it was started while the app was in the TOP state
+        //   2. the app is currently in the foreground
+        //   3. the app overall is within its quota
+        return isTopStartedJobLocked(jobStatus)
+                || isUidInForeground(jobStatus.getSourceUid())
+                || isWithinQuotaLocked(
+                jobStatus.getSourceUserId(), jobStatus.getSourcePackageName(), standbyBucket);
+    }
+
+    @VisibleForTesting
+    boolean isWithinQuotaLocked(final int userId, @NonNull final String packageName,
+            final int standbyBucket) {
+        if (standbyBucket == NEVER_INDEX) return false;
+        // This check is needed in case the flag is toggled after a job has been registered.
+        if (!mShouldThrottle) return true;
+
+        // Quota constraint is not enforced while charging or when parole is on.
+        if (mChargeTracker.isCharging() || mInParole) {
+            return true;
+        }
+
+        ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket);
+        return getRemainingExecutionTimeLocked(stats) > 0
+                && isUnderJobCountQuotaLocked(stats, standbyBucket)
+                && isUnderSessionCountQuotaLocked(stats, standbyBucket);
+    }
+
+    private boolean isUnderJobCountQuotaLocked(@NonNull ExecutionStats stats,
+            final int standbyBucket) {
+        final long now = sElapsedRealtimeClock.millis();
+        final boolean isUnderAllowedTimeQuota =
+                (stats.jobRateLimitExpirationTimeElapsed <= now
+                        || stats.jobCountInRateLimitingWindow < mMaxJobCountPerRateLimitingWindow);
+        return isUnderAllowedTimeQuota
+                && (stats.bgJobCountInWindow < mMaxBucketJobCounts[standbyBucket]);
+    }
+
+    private boolean isUnderSessionCountQuotaLocked(@NonNull ExecutionStats stats,
+            final int standbyBucket) {
+        final long now = sElapsedRealtimeClock.millis();
+        final boolean isUnderAllowedTimeQuota = (stats.sessionRateLimitExpirationTimeElapsed <= now
+                || stats.sessionCountInRateLimitingWindow < mMaxSessionCountPerRateLimitingWindow);
+        return isUnderAllowedTimeQuota
+                && stats.sessionCountInWindow < mMaxBucketSessionCounts[standbyBucket];
+    }
+
+    @VisibleForTesting
+    long getRemainingExecutionTimeLocked(@NonNull final JobStatus jobStatus) {
+        return getRemainingExecutionTimeLocked(jobStatus.getSourceUserId(),
+                jobStatus.getSourcePackageName(),
+                getEffectiveStandbyBucket(jobStatus));
+    }
+
+    @VisibleForTesting
+    long getRemainingExecutionTimeLocked(final int userId, @NonNull final String packageName) {
+        final int standbyBucket = JobSchedulerService.standbyBucketForPackage(packageName,
+                userId, sElapsedRealtimeClock.millis());
+        return getRemainingExecutionTimeLocked(userId, packageName, standbyBucket);
+    }
+
+    /**
+     * Returns the amount of time, in milliseconds, that this job has remaining to run based on its
+     * current standby bucket. Time remaining could be negative if the app was moved from a less
+     * restricted to a more restricted bucket.
+     */
+    private long getRemainingExecutionTimeLocked(final int userId,
+            @NonNull final String packageName, final int standbyBucket) {
+        if (standbyBucket == NEVER_INDEX) {
+            return 0;
+        }
+        return getRemainingExecutionTimeLocked(
+                getExecutionStatsLocked(userId, packageName, standbyBucket));
+    }
+
+    private long getRemainingExecutionTimeLocked(@NonNull ExecutionStats stats) {
+        return Math.min(mAllowedTimePerPeriodMs - stats.executionTimeInWindowMs,
+                mMaxExecutionTimeMs - stats.executionTimeInMaxPeriodMs);
+    }
+
+    /**
+     * Returns the amount of time, in milliseconds, until the package would have reached its
+     * duration quota, assuming it has a job counting towards its quota the entire time. This takes
+     * into account any {@link TimingSession}s that may roll out of the window as the job is
+     * running.
+     */
+    @VisibleForTesting
+    long getTimeUntilQuotaConsumedLocked(final int userId, @NonNull final String packageName) {
+        final long nowElapsed = sElapsedRealtimeClock.millis();
+        final int standbyBucket = JobSchedulerService.standbyBucketForPackage(
+                packageName, userId, nowElapsed);
+        if (standbyBucket == NEVER_INDEX) {
+            return 0;
+        }
+        List<TimingSession> sessions = mTimingSessions.get(userId, packageName);
+        if (sessions == null || sessions.size() == 0) {
+            return mAllowedTimePerPeriodMs;
+        }
+
+        final ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket);
+        final long startWindowElapsed = nowElapsed - stats.windowSizeMs;
+        final long startMaxElapsed = nowElapsed - MAX_PERIOD_MS;
+        final long allowedTimeRemainingMs = mAllowedTimePerPeriodMs - stats.executionTimeInWindowMs;
+        final long maxExecutionTimeRemainingMs =
+                mMaxExecutionTimeMs - stats.executionTimeInMaxPeriodMs;
+
+        // Regular ACTIVE case. Since the bucket size equals the allowed time, the app jobs can
+        // essentially run until they reach the maximum limit.
+        if (stats.windowSizeMs == mAllowedTimePerPeriodMs) {
+            return calculateTimeUntilQuotaConsumedLocked(
+                    sessions, startMaxElapsed, maxExecutionTimeRemainingMs);
+        }
+
+        // Need to check both max time and period time in case one is less than the other.
+        // For example, max time remaining could be less than bucket time remaining, but sessions
+        // contributing to the max time remaining could phase out enough that we'd want to use the
+        // bucket value.
+        return Math.min(
+                calculateTimeUntilQuotaConsumedLocked(
+                        sessions, startMaxElapsed, maxExecutionTimeRemainingMs),
+                calculateTimeUntilQuotaConsumedLocked(
+                        sessions, startWindowElapsed, allowedTimeRemainingMs));
+    }
+
+    /**
+     * Calculates how much time it will take, in milliseconds, until the quota is fully consumed.
+     *
+     * @param windowStartElapsed The start of the window, in the elapsed realtime timebase.
+     * @param deadSpaceMs        How much time can be allowed to count towards the quota
+     */
+    private long calculateTimeUntilQuotaConsumedLocked(@NonNull List<TimingSession> sessions,
+            final long windowStartElapsed, long deadSpaceMs) {
+        long timeUntilQuotaConsumedMs = 0;
+        long start = windowStartElapsed;
+        for (int i = 0; i < sessions.size(); ++i) {
+            TimingSession session = sessions.get(i);
+
+            if (session.endTimeElapsed < windowStartElapsed) {
+                // Outside of window. Ignore.
+                continue;
+            } else if (session.startTimeElapsed <= windowStartElapsed) {
+                // Overlapping session. Can extend time by portion of session in window.
+                timeUntilQuotaConsumedMs += session.endTimeElapsed - windowStartElapsed;
+                start = session.endTimeElapsed;
+            } else {
+                // Completely within the window. Can only consider if there's enough dead space
+                // to get to the start of the session.
+                long diff = session.startTimeElapsed - start;
+                if (diff > deadSpaceMs) {
+                    break;
+                }
+                timeUntilQuotaConsumedMs += diff
+                        + (session.endTimeElapsed - session.startTimeElapsed);
+                deadSpaceMs -= diff;
+                start = session.endTimeElapsed;
+            }
+        }
+        // Will be non-zero if the loop didn't look at any sessions.
+        timeUntilQuotaConsumedMs += deadSpaceMs;
+        if (timeUntilQuotaConsumedMs > mMaxExecutionTimeMs) {
+            Slog.wtf(TAG, "Calculated quota consumed time too high: " + timeUntilQuotaConsumedMs);
+        }
+        return timeUntilQuotaConsumedMs;
+    }
+
+    /** Returns the execution stats of the app in the most recent window. */
+    @VisibleForTesting
+    @NonNull
+    ExecutionStats getExecutionStatsLocked(final int userId, @NonNull final String packageName,
+            final int standbyBucket) {
+        return getExecutionStatsLocked(userId, packageName, standbyBucket, true);
+    }
+
+    @NonNull
+    private ExecutionStats getExecutionStatsLocked(final int userId,
+            @NonNull final String packageName, final int standbyBucket,
+            final boolean refreshStatsIfOld) {
+        if (standbyBucket == NEVER_INDEX) {
+            Slog.wtf(TAG, "getExecutionStatsLocked called for a NEVER app.");
+            return new ExecutionStats();
+        }
+        ExecutionStats[] appStats = mExecutionStatsCache.get(userId, packageName);
+        if (appStats == null) {
+            appStats = new ExecutionStats[mBucketPeriodsMs.length];
+            mExecutionStatsCache.add(userId, packageName, appStats);
+        }
+        ExecutionStats stats = appStats[standbyBucket];
+        if (stats == null) {
+            stats = new ExecutionStats();
+            appStats[standbyBucket] = stats;
+        }
+        if (refreshStatsIfOld) {
+            final long bucketWindowSizeMs = mBucketPeriodsMs[standbyBucket];
+            final int jobCountLimit = mMaxBucketJobCounts[standbyBucket];
+            final int sessionCountLimit = mMaxBucketSessionCounts[standbyBucket];
+            Timer timer = mPkgTimers.get(userId, packageName);
+            if ((timer != null && timer.isActive())
+                    || stats.expirationTimeElapsed <= sElapsedRealtimeClock.millis()
+                    || stats.windowSizeMs != bucketWindowSizeMs
+                    || stats.jobCountLimit != jobCountLimit
+                    || stats.sessionCountLimit != sessionCountLimit) {
+                // The stats are no longer valid.
+                stats.windowSizeMs = bucketWindowSizeMs;
+                stats.jobCountLimit = jobCountLimit;
+                stats.sessionCountLimit = sessionCountLimit;
+                updateExecutionStatsLocked(userId, packageName, stats);
+            }
+        }
+
+        return stats;
+    }
+
+    @VisibleForTesting
+    void updateExecutionStatsLocked(final int userId, @NonNull final String packageName,
+            @NonNull ExecutionStats stats) {
+        stats.executionTimeInWindowMs = 0;
+        stats.bgJobCountInWindow = 0;
+        stats.executionTimeInMaxPeriodMs = 0;
+        stats.bgJobCountInMaxPeriod = 0;
+        stats.sessionCountInWindow = 0;
+        stats.inQuotaTimeElapsed = 0;
+
+        Timer timer = mPkgTimers.get(userId, packageName);
+        final long nowElapsed = sElapsedRealtimeClock.millis();
+        stats.expirationTimeElapsed = nowElapsed + MAX_PERIOD_MS;
+        if (timer != null && timer.isActive()) {
+            stats.executionTimeInWindowMs =
+                    stats.executionTimeInMaxPeriodMs = timer.getCurrentDuration(nowElapsed);
+            stats.bgJobCountInWindow = stats.bgJobCountInMaxPeriod = timer.getBgJobCount();
+            // If the timer is active, the value will be stale at the next method call, so
+            // invalidate now.
+            stats.expirationTimeElapsed = nowElapsed;
+            if (stats.executionTimeInWindowMs >= mAllowedTimeIntoQuotaMs) {
+                stats.inQuotaTimeElapsed = Math.max(stats.inQuotaTimeElapsed,
+                        nowElapsed - mAllowedTimeIntoQuotaMs + stats.windowSizeMs);
+            }
+            if (stats.executionTimeInMaxPeriodMs >= mMaxExecutionTimeIntoQuotaMs) {
+                stats.inQuotaTimeElapsed = Math.max(stats.inQuotaTimeElapsed,
+                        nowElapsed - mMaxExecutionTimeIntoQuotaMs + MAX_PERIOD_MS);
+            }
+        }
+
+        List<TimingSession> sessions = mTimingSessions.get(userId, packageName);
+        if (sessions == null || sessions.size() == 0) {
+            return;
+        }
+
+        final long startWindowElapsed = nowElapsed - stats.windowSizeMs;
+        final long startMaxElapsed = nowElapsed - MAX_PERIOD_MS;
+        int sessionCountInWindow = 0;
+        // The minimum time between the start time and the beginning of the sessions that were
+        // looked at --> how much time the stats will be valid for.
+        long emptyTimeMs = Long.MAX_VALUE;
+        // Sessions are non-overlapping and in order of occurrence, so iterating backwards will get
+        // the most recent ones.
+        final int loopStart = sessions.size() - 1;
+        for (int i = loopStart; i >= 0; --i) {
+            TimingSession session = sessions.get(i);
+
+            // Window management.
+            if (startWindowElapsed < session.endTimeElapsed) {
+                final long start;
+                if (startWindowElapsed < session.startTimeElapsed) {
+                    start = session.startTimeElapsed;
+                    emptyTimeMs =
+                            Math.min(emptyTimeMs, session.startTimeElapsed - startWindowElapsed);
+                } else {
+                    // The session started before the window but ended within the window. Only
+                    // include the portion that was within the window.
+                    start = startWindowElapsed;
+                    emptyTimeMs = 0;
+                }
+
+                stats.executionTimeInWindowMs += session.endTimeElapsed - start;
+                stats.bgJobCountInWindow += session.bgJobCount;
+                if (stats.executionTimeInWindowMs >= mAllowedTimeIntoQuotaMs) {
+                    stats.inQuotaTimeElapsed = Math.max(stats.inQuotaTimeElapsed,
+                            start + stats.executionTimeInWindowMs - mAllowedTimeIntoQuotaMs
+                                    + stats.windowSizeMs);
+                }
+                if (stats.bgJobCountInWindow >= stats.jobCountLimit) {
+                    stats.inQuotaTimeElapsed = Math.max(stats.inQuotaTimeElapsed,
+                            session.endTimeElapsed + stats.windowSizeMs);
+                }
+                if (i == loopStart
+                        || (sessions.get(i + 1).startTimeElapsed - session.endTimeElapsed)
+                                > mTimingSessionCoalescingDurationMs) {
+                    // Coalesce sessions if they are very close to each other in time
+                    sessionCountInWindow++;
+
+                    if (sessionCountInWindow >= stats.sessionCountLimit) {
+                        stats.inQuotaTimeElapsed = Math.max(stats.inQuotaTimeElapsed,
+                                session.endTimeElapsed + stats.windowSizeMs);
+                    }
+                }
+            }
+
+            // Max period check.
+            if (startMaxElapsed < session.startTimeElapsed) {
+                stats.executionTimeInMaxPeriodMs +=
+                        session.endTimeElapsed - session.startTimeElapsed;
+                stats.bgJobCountInMaxPeriod += session.bgJobCount;
+                emptyTimeMs = Math.min(emptyTimeMs, session.startTimeElapsed - startMaxElapsed);
+                if (stats.executionTimeInMaxPeriodMs >= mMaxExecutionTimeIntoQuotaMs) {
+                    stats.inQuotaTimeElapsed = Math.max(stats.inQuotaTimeElapsed,
+                            session.startTimeElapsed + stats.executionTimeInMaxPeriodMs
+                                    - mMaxExecutionTimeIntoQuotaMs + MAX_PERIOD_MS);
+                }
+            } else if (startMaxElapsed < session.endTimeElapsed) {
+                // The session started before the window but ended within the window. Only include
+                // the portion that was within the window.
+                stats.executionTimeInMaxPeriodMs += session.endTimeElapsed - startMaxElapsed;
+                stats.bgJobCountInMaxPeriod += session.bgJobCount;
+                emptyTimeMs = 0;
+                if (stats.executionTimeInMaxPeriodMs >= mMaxExecutionTimeIntoQuotaMs) {
+                    stats.inQuotaTimeElapsed = Math.max(stats.inQuotaTimeElapsed,
+                            startMaxElapsed + stats.executionTimeInMaxPeriodMs
+                                    - mMaxExecutionTimeIntoQuotaMs + MAX_PERIOD_MS);
+                }
+            } else {
+                // This session ended before the window. No point in going any further.
+                break;
+            }
+        }
+        stats.expirationTimeElapsed = nowElapsed + emptyTimeMs;
+        stats.sessionCountInWindow = sessionCountInWindow;
+    }
+
+    /** Invalidate ExecutionStats for all apps. */
+    @VisibleForTesting
+    void invalidateAllExecutionStatsLocked() {
+        final long nowElapsed = sElapsedRealtimeClock.millis();
+        mExecutionStatsCache.forEach((appStats) -> {
+            if (appStats != null) {
+                for (int i = 0; i < appStats.length; ++i) {
+                    ExecutionStats stats = appStats[i];
+                    if (stats != null) {
+                        stats.expirationTimeElapsed = nowElapsed;
+                    }
+                }
+            }
+        });
+    }
+
+    @VisibleForTesting
+    void invalidateAllExecutionStatsLocked(final int userId,
+            @NonNull final String packageName) {
+        ExecutionStats[] appStats = mExecutionStatsCache.get(userId, packageName);
+        if (appStats != null) {
+            final long nowElapsed = sElapsedRealtimeClock.millis();
+            for (int i = 0; i < appStats.length; ++i) {
+                ExecutionStats stats = appStats[i];
+                if (stats != null) {
+                    stats.expirationTimeElapsed = nowElapsed;
+                }
+            }
+        }
+    }
+
+    @VisibleForTesting
+    void incrementJobCount(final int userId, @NonNull final String packageName, int count) {
+        final long now = sElapsedRealtimeClock.millis();
+        ExecutionStats[] appStats = mExecutionStatsCache.get(userId, packageName);
+        if (appStats == null) {
+            appStats = new ExecutionStats[mBucketPeriodsMs.length];
+            mExecutionStatsCache.add(userId, packageName, appStats);
+        }
+        for (int i = 0; i < appStats.length; ++i) {
+            ExecutionStats stats = appStats[i];
+            if (stats == null) {
+                stats = new ExecutionStats();
+                appStats[i] = stats;
+            }
+            if (stats.jobRateLimitExpirationTimeElapsed <= now) {
+                stats.jobRateLimitExpirationTimeElapsed = now + mRateLimitingWindowMs;
+                stats.jobCountInRateLimitingWindow = 0;
+            }
+            stats.jobCountInRateLimitingWindow += count;
+        }
+    }
+
+    private void incrementTimingSessionCount(final int userId, @NonNull final String packageName) {
+        final long now = sElapsedRealtimeClock.millis();
+        ExecutionStats[] appStats = mExecutionStatsCache.get(userId, packageName);
+        if (appStats == null) {
+            appStats = new ExecutionStats[mBucketPeriodsMs.length];
+            mExecutionStatsCache.add(userId, packageName, appStats);
+        }
+        for (int i = 0; i < appStats.length; ++i) {
+            ExecutionStats stats = appStats[i];
+            if (stats == null) {
+                stats = new ExecutionStats();
+                appStats[i] = stats;
+            }
+            if (stats.sessionRateLimitExpirationTimeElapsed <= now) {
+                stats.sessionRateLimitExpirationTimeElapsed = now + mRateLimitingWindowMs;
+                stats.sessionCountInRateLimitingWindow = 0;
+            }
+            stats.sessionCountInRateLimitingWindow++;
+        }
+    }
+
+    @VisibleForTesting
+    void saveTimingSession(final int userId, @NonNull final String packageName,
+            @NonNull final TimingSession session) {
+        synchronized (mLock) {
+            List<TimingSession> sessions = mTimingSessions.get(userId, packageName);
+            if (sessions == null) {
+                sessions = new ArrayList<>();
+                mTimingSessions.add(userId, packageName, sessions);
+            }
+            sessions.add(session);
+            // Adding a new session means that the current stats are now incorrect.
+            invalidateAllExecutionStatsLocked(userId, packageName);
+
+            maybeScheduleCleanupAlarmLocked();
+        }
+    }
+
+    private final class EarliestEndTimeFunctor implements Consumer<List<TimingSession>> {
+        public long earliestEndElapsed = Long.MAX_VALUE;
+
+        @Override
+        public void accept(List<TimingSession> sessions) {
+            if (sessions != null && sessions.size() > 0) {
+                earliestEndElapsed = Math.min(earliestEndElapsed, sessions.get(0).endTimeElapsed);
+            }
+        }
+
+        void reset() {
+            earliestEndElapsed = Long.MAX_VALUE;
+        }
+    }
+
+    private final EarliestEndTimeFunctor mEarliestEndTimeFunctor = new EarliestEndTimeFunctor();
+
+    /** Schedule a cleanup alarm if necessary and there isn't already one scheduled. */
+    @VisibleForTesting
+    void maybeScheduleCleanupAlarmLocked() {
+        if (mNextCleanupTimeElapsed > sElapsedRealtimeClock.millis()) {
+            // There's already an alarm scheduled. Just stick with that one. There's no way we'll
+            // end up scheduling an earlier alarm.
+            if (DEBUG) {
+                Slog.v(TAG, "Not scheduling cleanup since there's already one at "
+                        + mNextCleanupTimeElapsed + " (in " + (mNextCleanupTimeElapsed
+                        - sElapsedRealtimeClock.millis()) + "ms)");
+            }
+            return;
+        }
+        mEarliestEndTimeFunctor.reset();
+        mTimingSessions.forEach(mEarliestEndTimeFunctor);
+        final long earliestEndElapsed = mEarliestEndTimeFunctor.earliestEndElapsed;
+        if (earliestEndElapsed == Long.MAX_VALUE) {
+            // Couldn't find a good time to clean up. Maybe this was called after we deleted all
+            // timing sessions.
+            if (DEBUG) {
+                Slog.d(TAG, "Didn't find a time to schedule cleanup");
+            }
+            return;
+        }
+        // Need to keep sessions for all apps up to the max period, regardless of their current
+        // standby bucket.
+        long nextCleanupElapsed = earliestEndElapsed + MAX_PERIOD_MS;
+        if (nextCleanupElapsed - mNextCleanupTimeElapsed <= 10 * MINUTE_IN_MILLIS) {
+            // No need to clean up too often. Delay the alarm if the next cleanup would be too soon
+            // after it.
+            nextCleanupElapsed += 10 * MINUTE_IN_MILLIS;
+        }
+        mNextCleanupTimeElapsed = nextCleanupElapsed;
+        mAlarmManager.set(AlarmManager.ELAPSED_REALTIME, nextCleanupElapsed, ALARM_TAG_CLEANUP,
+                mSessionCleanupAlarmListener, mHandler);
+        if (DEBUG) {
+            Slog.d(TAG, "Scheduled next cleanup for " + mNextCleanupTimeElapsed);
+        }
+    }
+
+    private void handleNewChargingStateLocked() {
+        final long nowElapsed = sElapsedRealtimeClock.millis();
+        final boolean isCharging = mChargeTracker.isCharging();
+        if (DEBUG) {
+            Slog.d(TAG, "handleNewChargingStateLocked: " + isCharging);
+        }
+        // Deal with Timers first.
+        mPkgTimers.forEach((t) -> t.onStateChangedLocked(nowElapsed, isCharging));
+        // Now update jobs.
+        maybeUpdateAllConstraintsLocked();
+    }
+
+    private void maybeUpdateAllConstraintsLocked() {
+        boolean changed = false;
+        for (int u = 0; u < mTrackedJobs.numUsers(); ++u) {
+            final int userId = mTrackedJobs.keyAt(u);
+            for (int p = 0; p < mTrackedJobs.numPackagesForUser(userId); ++p) {
+                final String packageName = mTrackedJobs.keyAt(u, p);
+                changed |= maybeUpdateConstraintForPkgLocked(userId, packageName);
+            }
+        }
+        if (changed) {
+            mStateChangedListener.onControllerStateChanged();
+        }
+    }
+
+    /**
+     * Update the CONSTRAINT_WITHIN_QUOTA bit for all of the Jobs for a given package.
+     *
+     * @return true if at least one job had its bit changed
+     */
+    private boolean maybeUpdateConstraintForPkgLocked(final int userId,
+            @NonNull final String packageName) {
+        ArraySet<JobStatus> jobs = mTrackedJobs.get(userId, packageName);
+        if (jobs == null || jobs.size() == 0) {
+            return false;
+        }
+
+        // Quota is the same for all jobs within a package.
+        final int realStandbyBucket = jobs.valueAt(0).getStandbyBucket();
+        final boolean realInQuota = isWithinQuotaLocked(userId, packageName, realStandbyBucket);
+        boolean changed = false;
+        for (int i = jobs.size() - 1; i >= 0; --i) {
+            final JobStatus js = jobs.valueAt(i);
+            if (isTopStartedJobLocked(js)) {
+                // Job was started while the app was in the TOP state so we should allow it to
+                // finish.
+                changed |= js.setQuotaConstraintSatisfied(true);
+            } else if (realStandbyBucket != ACTIVE_INDEX
+                    && realStandbyBucket == getEffectiveStandbyBucket(js)) {
+                // An app in the ACTIVE bucket may be out of quota while the job could be in quota
+                // for some reason. Therefore, avoid setting the real value here and check each job
+                // individually.
+                changed |= setConstraintSatisfied(js, realInQuota);
+            } else {
+                // This job is somehow exempted. Need to determine its own quota status.
+                changed |= setConstraintSatisfied(js, isWithinQuotaLocked(js));
+            }
+        }
+        if (!realInQuota) {
+            // Don't want to use the effective standby bucket here since that bump the bucket to
+            // ACTIVE for one of the jobs, which doesn't help with other jobs that aren't
+            // exempted.
+            maybeScheduleStartAlarmLocked(userId, packageName, realStandbyBucket);
+        } else {
+            QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, packageName);
+            if (alarmListener != null && alarmListener.isWaiting()) {
+                mAlarmManager.cancel(alarmListener);
+                // Set the trigger time to 0 so that the alarm doesn't think it's still waiting.
+                alarmListener.setTriggerTime(0);
+            }
+        }
+        return changed;
+    }
+
+    private class UidConstraintUpdater implements Consumer<JobStatus> {
+        private final UserPackageMap<Integer> mToScheduleStartAlarms = new UserPackageMap<>();
+        public boolean wasJobChanged;
+
+        @Override
+        public void accept(JobStatus jobStatus) {
+            wasJobChanged |= setConstraintSatisfied(jobStatus, isWithinQuotaLocked(jobStatus));
+            final int userId = jobStatus.getSourceUserId();
+            final String packageName = jobStatus.getSourcePackageName();
+            final int realStandbyBucket = jobStatus.getStandbyBucket();
+            if (isWithinQuotaLocked(userId, packageName, realStandbyBucket)) {
+                QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, packageName);
+                if (alarmListener != null && alarmListener.isWaiting()) {
+                    mAlarmManager.cancel(alarmListener);
+                    // Set the trigger time to 0 so that the alarm doesn't think it's still waiting.
+                    alarmListener.setTriggerTime(0);
+                }
+            } else {
+                mToScheduleStartAlarms.add(userId, packageName, realStandbyBucket);
+            }
+        }
+
+        void postProcess() {
+            for (int u = 0; u < mToScheduleStartAlarms.numUsers(); ++u) {
+                final int userId = mToScheduleStartAlarms.keyAt(u);
+                for (int p = 0; p < mToScheduleStartAlarms.numPackagesForUser(userId); ++p) {
+                    final String packageName = mToScheduleStartAlarms.keyAt(u, p);
+                    final int standbyBucket = mToScheduleStartAlarms.get(userId, packageName);
+                    maybeScheduleStartAlarmLocked(userId, packageName, standbyBucket);
+                }
+            }
+        }
+
+        void reset() {
+            wasJobChanged = false;
+            mToScheduleStartAlarms.clear();
+        }
+    }
+
+    private final UidConstraintUpdater mUpdateUidConstraints = new UidConstraintUpdater();
+
+    private boolean maybeUpdateConstraintForUidLocked(final int uid) {
+        mService.getJobStore().forEachJobForSourceUid(uid, mUpdateUidConstraints);
+
+        mUpdateUidConstraints.postProcess();
+        boolean changed = mUpdateUidConstraints.wasJobChanged;
+        mUpdateUidConstraints.reset();
+        return changed;
+    }
+
+    /**
+     * Maybe schedule a non-wakeup alarm for the next time this package will have quota to run
+     * again. This should only be called if the package is already out of quota.
+     */
+    @VisibleForTesting
+    void maybeScheduleStartAlarmLocked(final int userId, @NonNull final String packageName,
+            final int standbyBucket) {
+        if (standbyBucket == NEVER_INDEX) {
+            return;
+        }
+
+        final String pkgString = string(userId, packageName);
+        ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket);
+        final boolean isUnderJobCountQuota = isUnderJobCountQuotaLocked(stats, standbyBucket);
+        final boolean isUnderTimingSessionCountQuota = isUnderSessionCountQuotaLocked(stats,
+                standbyBucket);
+
+        QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, packageName);
+        if (stats.executionTimeInWindowMs < mAllowedTimePerPeriodMs
+                && stats.executionTimeInMaxPeriodMs < mMaxExecutionTimeMs
+                && isUnderJobCountQuota
+                && isUnderTimingSessionCountQuota) {
+            // Already in quota. Why was this method called?
+            if (DEBUG) {
+                Slog.e(TAG, "maybeScheduleStartAlarmLocked called for " + pkgString
+                        + " even though it already has "
+                        + getRemainingExecutionTimeLocked(userId, packageName, standbyBucket)
+                        + "ms in its quota.");
+            }
+            if (alarmListener != null) {
+                // Cancel any pending alarm.
+                mAlarmManager.cancel(alarmListener);
+                // Set the trigger time to 0 so that the alarm doesn't think it's still waiting.
+                alarmListener.setTriggerTime(0);
+            }
+            mHandler.obtainMessage(MSG_CHECK_PACKAGE, userId, 0, packageName).sendToTarget();
+            return;
+        }
+
+        if (alarmListener == null) {
+            alarmListener = new QcAlarmListener(userId, packageName);
+            mInQuotaAlarmListeners.add(userId, packageName, alarmListener);
+        }
+
+        // The time this app will have quota again.
+        long inQuotaTimeElapsed = stats.inQuotaTimeElapsed;
+        if (!isUnderJobCountQuota && stats.bgJobCountInWindow < stats.jobCountLimit) {
+            // App hit the rate limit.
+            inQuotaTimeElapsed = Math.max(inQuotaTimeElapsed,
+                    stats.jobRateLimitExpirationTimeElapsed);
+        }
+        if (!isUnderTimingSessionCountQuota
+                && stats.sessionCountInWindow < stats.sessionCountLimit) {
+            // App hit the rate limit.
+            inQuotaTimeElapsed = Math.max(inQuotaTimeElapsed,
+                    stats.sessionRateLimitExpirationTimeElapsed);
+        }
+        // Only schedule the alarm if:
+        // 1. There isn't one currently scheduled
+        // 2. The new alarm is significantly earlier than the previous alarm (which could be the
+        // case if the package moves into a higher standby bucket). If it's earlier but not
+        // significantly so, then we essentially delay the job a few extra minutes.
+        // 3. The alarm is after the current alarm by more than the quota buffer.
+        // TODO: this might be overengineering. Simplify if proven safe.
+        if (!alarmListener.isWaiting()
+                || inQuotaTimeElapsed < alarmListener.getTriggerTimeElapsed() - 3 * MINUTE_IN_MILLIS
+                || alarmListener.getTriggerTimeElapsed() < inQuotaTimeElapsed) {
+            if (DEBUG) {
+                Slog.d(TAG, "Scheduling start alarm for " + pkgString);
+            }
+            // If the next time this app will have quota is at least 3 minutes before the
+            // alarm is supposed to go off, reschedule the alarm.
+            mAlarmManager.set(AlarmManager.ELAPSED_REALTIME, inQuotaTimeElapsed,
+                    ALARM_TAG_QUOTA_CHECK, alarmListener, mHandler);
+            alarmListener.setTriggerTime(inQuotaTimeElapsed);
+        } else if (DEBUG) {
+            Slog.d(TAG, "No need to schedule start alarm for " + pkgString);
+        }
+    }
+
+    private boolean setConstraintSatisfied(@NonNull JobStatus jobStatus, boolean isWithinQuota) {
+        if (!isWithinQuota && jobStatus.getWhenStandbyDeferred() == 0) {
+            // Mark that the job is being deferred due to buckets.
+            jobStatus.setWhenStandbyDeferred(sElapsedRealtimeClock.millis());
+        }
+        return jobStatus.setQuotaConstraintSatisfied(isWithinQuota);
+    }
+
+    private final class ChargingTracker extends BroadcastReceiver {
+        /**
+         * Track whether we're charging. This has a slightly different definition than that of
+         * BatteryController.
+         */
+        private boolean mCharging;
+
+        ChargingTracker() {
+        }
+
+        public void startTracking() {
+            IntentFilter filter = new IntentFilter();
+
+            // Charging/not charging.
+            filter.addAction(BatteryManager.ACTION_CHARGING);
+            filter.addAction(BatteryManager.ACTION_DISCHARGING);
+            mContext.registerReceiver(this, filter);
+
+            // Initialise tracker state.
+            BatteryManagerInternal batteryManagerInternal =
+                    LocalServices.getService(BatteryManagerInternal.class);
+            mCharging = batteryManagerInternal.isPowered(BatteryManager.BATTERY_PLUGGED_ANY);
+        }
+
+        public boolean isCharging() {
+            return mCharging;
+        }
+
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            synchronized (mLock) {
+                final String action = intent.getAction();
+                if (BatteryManager.ACTION_CHARGING.equals(action)) {
+                    if (DEBUG) {
+                        Slog.d(TAG, "Received charging intent, fired @ "
+                                + sElapsedRealtimeClock.millis());
+                    }
+                    mCharging = true;
+                    handleNewChargingStateLocked();
+                } else if (BatteryManager.ACTION_DISCHARGING.equals(action)) {
+                    if (DEBUG) {
+                        Slog.d(TAG, "Disconnected from power.");
+                    }
+                    mCharging = false;
+                    handleNewChargingStateLocked();
+                }
+            }
+        }
+    }
+
+    @VisibleForTesting
+    static final class TimingSession {
+        // Start timestamp in elapsed realtime timebase.
+        public final long startTimeElapsed;
+        // End timestamp in elapsed realtime timebase.
+        public final long endTimeElapsed;
+        // How many background jobs ran during this session.
+        public final int bgJobCount;
+
+        private final int mHashCode;
+
+        TimingSession(long startElapsed, long endElapsed, int bgJobCount) {
+            this.startTimeElapsed = startElapsed;
+            this.endTimeElapsed = endElapsed;
+            this.bgJobCount = bgJobCount;
+
+            int hashCode = 0;
+            hashCode = 31 * hashCode + hashLong(startTimeElapsed);
+            hashCode = 31 * hashCode + hashLong(endTimeElapsed);
+            hashCode = 31 * hashCode + bgJobCount;
+            mHashCode = hashCode;
+        }
+
+        @Override
+        public String toString() {
+            return "TimingSession{" + startTimeElapsed + "->" + endTimeElapsed + ", " + bgJobCount
+                    + "}";
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (obj instanceof TimingSession) {
+                TimingSession other = (TimingSession) obj;
+                return startTimeElapsed == other.startTimeElapsed
+                        && endTimeElapsed == other.endTimeElapsed
+                        && bgJobCount == other.bgJobCount;
+            } else {
+                return false;
+            }
+        }
+
+        @Override
+        public int hashCode() {
+            return mHashCode;
+        }
+
+        public void dump(IndentingPrintWriter pw) {
+            pw.print(startTimeElapsed);
+            pw.print(" -> ");
+            pw.print(endTimeElapsed);
+            pw.print(" (");
+            pw.print(endTimeElapsed - startTimeElapsed);
+            pw.print("), ");
+            pw.print(bgJobCount);
+            pw.print(" bg jobs.");
+            pw.println();
+        }
+
+        public void dump(@NonNull ProtoOutputStream proto, long fieldId) {
+            final long token = proto.start(fieldId);
+
+            proto.write(StateControllerProto.QuotaController.TimingSession.START_TIME_ELAPSED,
+                    startTimeElapsed);
+            proto.write(StateControllerProto.QuotaController.TimingSession.END_TIME_ELAPSED,
+                    endTimeElapsed);
+            proto.write(StateControllerProto.QuotaController.TimingSession.BG_JOB_COUNT,
+                    bgJobCount);
+
+            proto.end(token);
+        }
+    }
+
+    private final class Timer {
+        private final Package mPkg;
+        private final int mUid;
+
+        // List of jobs currently running for this app that started when the app wasn't in the
+        // foreground.
+        private final ArraySet<JobStatus> mRunningBgJobs = new ArraySet<>();
+        private long mStartTimeElapsed;
+        private int mBgJobCount;
+
+        Timer(int uid, int userId, String packageName) {
+            mPkg = new Package(userId, packageName);
+            mUid = uid;
+        }
+
+        void startTrackingJobLocked(@NonNull JobStatus jobStatus) {
+            if (isTopStartedJobLocked(jobStatus)) {
+                // We intentionally don't pay attention to fg state changes after a TOP job has
+                // started.
+                if (DEBUG) {
+                    Slog.v(TAG,
+                            "Timer ignoring " + jobStatus.toShortString() + " because isTop");
+                }
+                return;
+            }
+            if (DEBUG) {
+                Slog.v(TAG, "Starting to track " + jobStatus.toShortString());
+            }
+            // Always track jobs, even when charging.
+            mRunningBgJobs.add(jobStatus);
+            if (shouldTrackLocked()) {
+                mBgJobCount++;
+                incrementJobCount(mPkg.userId, mPkg.packageName, 1);
+                if (mRunningBgJobs.size() == 1) {
+                    // Started tracking the first job.
+                    mStartTimeElapsed = sElapsedRealtimeClock.millis();
+                    // Starting the timer means that all cached execution stats are now incorrect.
+                    invalidateAllExecutionStatsLocked(mPkg.userId, mPkg.packageName);
+                    scheduleCutoff();
+                }
+            }
+        }
+
+        void stopTrackingJob(@NonNull JobStatus jobStatus) {
+            if (DEBUG) {
+                Slog.v(TAG, "Stopping tracking of " + jobStatus.toShortString());
+            }
+            synchronized (mLock) {
+                if (mRunningBgJobs.size() == 0) {
+                    // maybeStopTrackingJobLocked can be called when an app cancels a job, so a
+                    // timer may not be running when it's asked to stop tracking a job.
+                    if (DEBUG) {
+                        Slog.d(TAG, "Timer isn't tracking any jobs but still told to stop");
+                    }
+                    return;
+                }
+                if (mRunningBgJobs.remove(jobStatus)
+                        && !mChargeTracker.isCharging() && mRunningBgJobs.size() == 0) {
+                    emitSessionLocked(sElapsedRealtimeClock.millis());
+                    cancelCutoff();
+                }
+            }
+        }
+
+        /**
+         * Stops tracking all jobs and cancels any pending alarms. This should only be called if
+         * the Timer is not going to be used anymore.
+         */
+        void dropEverythingLocked() {
+            mRunningBgJobs.clear();
+            cancelCutoff();
+        }
+
+        private void emitSessionLocked(long nowElapsed) {
+            if (mBgJobCount <= 0) {
+                // Nothing to emit.
+                return;
+            }
+            TimingSession ts = new TimingSession(mStartTimeElapsed, nowElapsed, mBgJobCount);
+            saveTimingSession(mPkg.userId, mPkg.packageName, ts);
+            mBgJobCount = 0;
+            // Don't reset the tracked jobs list as we need to keep tracking the current number
+            // of jobs.
+            // However, cancel the currently scheduled cutoff since it's not currently useful.
+            cancelCutoff();
+            incrementTimingSessionCount(mPkg.userId, mPkg.packageName);
+        }
+
+        /**
+         * Returns true if the Timer is actively tracking, as opposed to passively ref counting
+         * during charging.
+         */
+        public boolean isActive() {
+            synchronized (mLock) {
+                return mBgJobCount > 0;
+            }
+        }
+
+        boolean isRunning(JobStatus jobStatus) {
+            return mRunningBgJobs.contains(jobStatus);
+        }
+
+        long getCurrentDuration(long nowElapsed) {
+            synchronized (mLock) {
+                return !isActive() ? 0 : nowElapsed - mStartTimeElapsed;
+            }
+        }
+
+        int getBgJobCount() {
+            synchronized (mLock) {
+                return mBgJobCount;
+            }
+        }
+
+        private boolean shouldTrackLocked() {
+            return !mChargeTracker.isCharging() && !mForegroundUids.get(mUid);
+        }
+
+        void onStateChangedLocked(long nowElapsed, boolean isQuotaFree) {
+            if (isQuotaFree) {
+                emitSessionLocked(nowElapsed);
+            } else if (!isActive() && shouldTrackLocked()) {
+                // Start timing from unplug.
+                if (mRunningBgJobs.size() > 0) {
+                    mStartTimeElapsed = nowElapsed;
+                    // NOTE: this does have the unfortunate consequence that if the device is
+                    // repeatedly plugged in and unplugged, or an app changes foreground state
+                    // very frequently, the job count for a package may be artificially high.
+                    mBgJobCount = mRunningBgJobs.size();
+                    incrementJobCount(mPkg.userId, mPkg.packageName, mBgJobCount);
+                    // Starting the timer means that all cached execution stats are now
+                    // incorrect.
+                    invalidateAllExecutionStatsLocked(mPkg.userId, mPkg.packageName);
+                    // Schedule cutoff since we're now actively tracking for quotas again.
+                    scheduleCutoff();
+                }
+            }
+        }
+
+        void rescheduleCutoff() {
+            cancelCutoff();
+            scheduleCutoff();
+        }
+
+        private void scheduleCutoff() {
+            // Each package can only be in one standby bucket, so we only need to have one
+            // message per timer. We only need to reschedule when restarting timer or when
+            // standby bucket changes.
+            synchronized (mLock) {
+                if (!isActive()) {
+                    return;
+                }
+                Message msg = mHandler.obtainMessage(MSG_REACHED_QUOTA, mPkg);
+                final long timeRemainingMs = getTimeUntilQuotaConsumedLocked(mPkg.userId,
+                        mPkg.packageName);
+                if (DEBUG) {
+                    Slog.i(TAG, "Job for " + mPkg + " has " + timeRemainingMs + "ms left.");
+                }
+                // If the job was running the entire time, then the system would be up, so it's
+                // fine to use uptime millis for these messages.
+                mHandler.sendMessageDelayed(msg, timeRemainingMs);
+            }
+        }
+
+        private void cancelCutoff() {
+            mHandler.removeMessages(MSG_REACHED_QUOTA, mPkg);
+        }
+
+        public void dump(IndentingPrintWriter pw, Predicate<JobStatus> predicate) {
+            pw.print("Timer{");
+            pw.print(mPkg);
+            pw.print("} ");
+            if (isActive()) {
+                pw.print("started at ");
+                pw.print(mStartTimeElapsed);
+                pw.print(" (");
+                pw.print(sElapsedRealtimeClock.millis() - mStartTimeElapsed);
+                pw.print("ms ago)");
+            } else {
+                pw.print("NOT active");
+            }
+            pw.print(", ");
+            pw.print(mBgJobCount);
+            pw.print(" running bg jobs");
+            pw.println();
+            pw.increaseIndent();
+            for (int i = 0; i < mRunningBgJobs.size(); i++) {
+                JobStatus js = mRunningBgJobs.valueAt(i);
+                if (predicate.test(js)) {
+                    pw.println(js.toShortString());
+                }
+            }
+            pw.decreaseIndent();
+        }
+
+        public void dump(ProtoOutputStream proto, long fieldId, Predicate<JobStatus> predicate) {
+            final long token = proto.start(fieldId);
+
+            mPkg.writeToProto(proto, StateControllerProto.QuotaController.Timer.PKG);
+            proto.write(StateControllerProto.QuotaController.Timer.IS_ACTIVE, isActive());
+            proto.write(StateControllerProto.QuotaController.Timer.START_TIME_ELAPSED,
+                    mStartTimeElapsed);
+            proto.write(StateControllerProto.QuotaController.Timer.BG_JOB_COUNT, mBgJobCount);
+            for (int i = 0; i < mRunningBgJobs.size(); i++) {
+                JobStatus js = mRunningBgJobs.valueAt(i);
+                if (predicate.test(js)) {
+                    js.writeToShortProto(proto,
+                            StateControllerProto.QuotaController.Timer.RUNNING_JOBS);
+                }
+            }
+
+            proto.end(token);
+        }
+    }
+
+    /**
+     * Tracking of app assignments to standby buckets
+     */
+    final class StandbyTracker extends AppIdleStateChangeListener {
+
+        @Override
+        public void onAppIdleStateChanged(final String packageName, final @UserIdInt int userId,
+                boolean idle, int bucket, int reason) {
+            // Update job bookkeeping out of band.
+            BackgroundThread.getHandler().post(() -> {
+                final int bucketIndex = JobSchedulerService.standbyBucketToBucketIndex(bucket);
+                if (DEBUG) {
+                    Slog.i(TAG, "Moving pkg " + string(userId, packageName) + " to bucketIndex "
+                            + bucketIndex);
+                }
+                synchronized (mLock) {
+                    ArraySet<JobStatus> jobs = mTrackedJobs.get(userId, packageName);
+                    if (jobs == null || jobs.size() == 0) {
+                        return;
+                    }
+                    for (int i = jobs.size() - 1; i >= 0; i--) {
+                        JobStatus js = jobs.valueAt(i);
+                        js.setStandbyBucket(bucketIndex);
+                    }
+                    Timer timer = mPkgTimers.get(userId, packageName);
+                    if (timer != null && timer.isActive()) {
+                        timer.rescheduleCutoff();
+                    }
+                    if (!mShouldThrottle || maybeUpdateConstraintForPkgLocked(userId,
+                            packageName)) {
+                        mStateChangedListener.onControllerStateChanged();
+                    }
+                }
+            });
+        }
+
+        @Override
+        public void onParoleStateChanged(final boolean isParoleOn) {
+            mInParole = isParoleOn;
+            if (DEBUG) {
+                Slog.i(TAG, "Global parole state now " + (isParoleOn ? "ON" : "OFF"));
+            }
+            // Update job bookkeeping out of band.
+            BackgroundThread.getHandler().post(() -> {
+                synchronized (mLock) {
+                    maybeUpdateAllConstraintsLocked();
+                }
+            });
+        }
+    }
+
+    private final class DeleteTimingSessionsFunctor implements Consumer<List<TimingSession>> {
+        private final Predicate<TimingSession> mTooOld = new Predicate<TimingSession>() {
+            public boolean test(TimingSession ts) {
+                return ts.endTimeElapsed <= sElapsedRealtimeClock.millis() - MAX_PERIOD_MS;
+            }
+        };
+
+        @Override
+        public void accept(List<TimingSession> sessions) {
+            if (sessions != null) {
+                // Remove everything older than MAX_PERIOD_MS time ago.
+                sessions.removeIf(mTooOld);
+            }
+        }
+    }
+
+    private final DeleteTimingSessionsFunctor mDeleteOldSessionsFunctor =
+            new DeleteTimingSessionsFunctor();
+
+    @VisibleForTesting
+    void deleteObsoleteSessionsLocked() {
+        mTimingSessions.forEach(mDeleteOldSessionsFunctor);
+    }
+
+    private class QcHandler extends Handler {
+        QcHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            synchronized (mLock) {
+                switch (msg.what) {
+                    case MSG_REACHED_QUOTA: {
+                        Package pkg = (Package) msg.obj;
+                        if (DEBUG) {
+                            Slog.d(TAG, "Checking if " + pkg + " has reached its quota.");
+                        }
+
+                        long timeRemainingMs = getRemainingExecutionTimeLocked(pkg.userId,
+                                pkg.packageName);
+                        if (timeRemainingMs <= 50) {
+                            // Less than 50 milliseconds left. Start process of shutting down jobs.
+                            if (DEBUG) Slog.d(TAG, pkg + " has reached its quota.");
+                            if (maybeUpdateConstraintForPkgLocked(pkg.userId, pkg.packageName)) {
+                                mStateChangedListener.onControllerStateChanged();
+                            }
+                        } else {
+                            // This could potentially happen if an old session phases out while a
+                            // job is currently running.
+                            // Reschedule message
+                            Message rescheduleMsg = obtainMessage(MSG_REACHED_QUOTA, pkg);
+                            timeRemainingMs = getTimeUntilQuotaConsumedLocked(pkg.userId,
+                                    pkg.packageName);
+                            if (DEBUG) {
+                                Slog.d(TAG, pkg + " has " + timeRemainingMs + "ms left.");
+                            }
+                            sendMessageDelayed(rescheduleMsg, timeRemainingMs);
+                        }
+                        break;
+                    }
+                    case MSG_CLEAN_UP_SESSIONS:
+                        if (DEBUG) {
+                            Slog.d(TAG, "Cleaning up timing sessions.");
+                        }
+                        deleteObsoleteSessionsLocked();
+                        maybeScheduleCleanupAlarmLocked();
+
+                        break;
+                    case MSG_CHECK_PACKAGE: {
+                        String packageName = (String) msg.obj;
+                        int userId = msg.arg1;
+                        if (DEBUG) {
+                            Slog.d(TAG, "Checking pkg " + string(userId, packageName));
+                        }
+                        if (maybeUpdateConstraintForPkgLocked(userId, packageName)) {
+                            mStateChangedListener.onControllerStateChanged();
+                        }
+                        break;
+                    }
+                    case MSG_UID_PROCESS_STATE_CHANGED: {
+                        final int uid = msg.arg1;
+                        final int procState = msg.arg2;
+                        final int userId = UserHandle.getUserId(uid);
+                        final long nowElapsed = sElapsedRealtimeClock.millis();
+
+                        synchronized (mLock) {
+                            boolean isQuotaFree;
+                            if (procState <= ActivityManager.PROCESS_STATE_FOREGROUND_SERVICE) {
+                                mForegroundUids.put(uid, true);
+                                isQuotaFree = true;
+                            } else {
+                                mForegroundUids.delete(uid);
+                                isQuotaFree = false;
+                            }
+                            // Update Timers first.
+                            if (mPkgTimers.indexOfKey(userId) >= 0) {
+                                ArraySet<String> packages = mUidToPackageCache.get(uid);
+                                if (packages == null) {
+                                    try {
+                                        String[] pkgs = AppGlobals.getPackageManager()
+                                                .getPackagesForUid(uid);
+                                        if (pkgs != null) {
+                                            for (String pkg : pkgs) {
+                                                mUidToPackageCache.add(uid, pkg);
+                                            }
+                                            packages = mUidToPackageCache.get(uid);
+                                        }
+                                    } catch (RemoteException e) {
+                                        Slog.wtf(TAG, "Failed to get package list", e);
+                                    }
+                                }
+                                if (packages != null) {
+                                    for (int i = packages.size() - 1; i >= 0; --i) {
+                                        Timer t = mPkgTimers.get(userId, packages.valueAt(i));
+                                        if (t != null) {
+                                            t.onStateChangedLocked(nowElapsed, isQuotaFree);
+                                        }
+                                    }
+                                }
+                            }
+                            if (maybeUpdateConstraintForUidLocked(uid)) {
+                                mStateChangedListener.onControllerStateChanged();
+                            }
+                        }
+                        break;
+                    }
+                }
+            }
+        }
+    }
+
+    private class QcAlarmListener implements AlarmManager.OnAlarmListener {
+        private final int mUserId;
+        private final String mPackageName;
+        private volatile long mTriggerTimeElapsed;
+
+        QcAlarmListener(int userId, String packageName) {
+            mUserId = userId;
+            mPackageName = packageName;
+        }
+
+        boolean isWaiting() {
+            return mTriggerTimeElapsed > 0;
+        }
+
+        void setTriggerTime(long timeElapsed) {
+            mTriggerTimeElapsed = timeElapsed;
+        }
+
+        long getTriggerTimeElapsed() {
+            return mTriggerTimeElapsed;
+        }
+
+        @Override
+        public void onAlarm() {
+            mHandler.obtainMessage(MSG_CHECK_PACKAGE, mUserId, 0, mPackageName).sendToTarget();
+            mTriggerTimeElapsed = 0;
+        }
+    }
+
+    @VisibleForTesting
+    class QcConstants extends ContentObserver {
+        private ContentResolver mResolver;
+        private final KeyValueListParser mParser = new KeyValueListParser(',');
+
+        private static final String KEY_ALLOWED_TIME_PER_PERIOD_MS = "allowed_time_per_period_ms";
+        private static final String KEY_IN_QUOTA_BUFFER_MS = "in_quota_buffer_ms";
+        private static final String KEY_WINDOW_SIZE_ACTIVE_MS = "window_size_active_ms";
+        private static final String KEY_WINDOW_SIZE_WORKING_MS = "window_size_working_ms";
+        private static final String KEY_WINDOW_SIZE_FREQUENT_MS = "window_size_frequent_ms";
+        private static final String KEY_WINDOW_SIZE_RARE_MS = "window_size_rare_ms";
+        private static final String KEY_MAX_EXECUTION_TIME_MS = "max_execution_time_ms";
+        private static final String KEY_MAX_JOB_COUNT_ACTIVE = "max_job_count_active";
+        private static final String KEY_MAX_JOB_COUNT_WORKING = "max_job_count_working";
+        private static final String KEY_MAX_JOB_COUNT_FREQUENT = "max_job_count_frequent";
+        private static final String KEY_MAX_JOB_COUNT_RARE = "max_job_count_rare";
+        private static final String KEY_RATE_LIMITING_WINDOW_MS = "rate_limiting_window_ms";
+        private static final String KEY_MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW =
+                "max_job_count_per_rate_limiting_window";
+        private static final String KEY_MAX_SESSION_COUNT_ACTIVE = "max_session_count_active";
+        private static final String KEY_MAX_SESSION_COUNT_WORKING = "max_session_count_working";
+        private static final String KEY_MAX_SESSION_COUNT_FREQUENT = "max_session_count_frequent";
+        private static final String KEY_MAX_SESSION_COUNT_RARE = "max_session_count_rare";
+        private static final String KEY_MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW =
+                "max_session_count_per_rate_limiting_window";
+        private static final String KEY_TIMING_SESSION_COALESCING_DURATION_MS =
+                "timing_session_coalescing_duration_ms";
+
+        private static final long DEFAULT_ALLOWED_TIME_PER_PERIOD_MS =
+                10 * 60 * 1000L; // 10 minutes
+        private static final long DEFAULT_IN_QUOTA_BUFFER_MS =
+                30 * 1000L; // 30 seconds
+        private static final long DEFAULT_WINDOW_SIZE_ACTIVE_MS =
+                DEFAULT_ALLOWED_TIME_PER_PERIOD_MS; // ACTIVE apps can run jobs at any time
+        private static final long DEFAULT_WINDOW_SIZE_WORKING_MS =
+                2 * 60 * 60 * 1000L; // 2 hours
+        private static final long DEFAULT_WINDOW_SIZE_FREQUENT_MS =
+                8 * 60 * 60 * 1000L; // 8 hours
+        private static final long DEFAULT_WINDOW_SIZE_RARE_MS =
+                24 * 60 * 60 * 1000L; // 24 hours
+        private static final long DEFAULT_MAX_EXECUTION_TIME_MS =
+                4 * HOUR_IN_MILLIS;
+        private static final long DEFAULT_RATE_LIMITING_WINDOW_MS =
+                10 * MINUTE_IN_MILLIS;
+        private static final int DEFAULT_MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW = 20;
+        private static final int DEFAULT_MAX_JOB_COUNT_ACTIVE = // 20/window = 120/hr = 1/session
+                DEFAULT_MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW;
+        private static final int DEFAULT_MAX_JOB_COUNT_WORKING = // 120/window = 60/hr = 12/session
+                (int) (60.0 * DEFAULT_WINDOW_SIZE_WORKING_MS / HOUR_IN_MILLIS);
+        private static final int DEFAULT_MAX_JOB_COUNT_FREQUENT = // 200/window = 25/hr = 25/session
+                (int) (25.0 * DEFAULT_WINDOW_SIZE_FREQUENT_MS / HOUR_IN_MILLIS);
+        private static final int DEFAULT_MAX_JOB_COUNT_RARE = // 48/window = 2/hr = 16/session
+                (int) (2.0 * DEFAULT_WINDOW_SIZE_RARE_MS / HOUR_IN_MILLIS);
+        private static final int DEFAULT_MAX_SESSION_COUNT_ACTIVE =
+                20; // 120/hr
+        private static final int DEFAULT_MAX_SESSION_COUNT_WORKING =
+                10; // 5/hr
+        private static final int DEFAULT_MAX_SESSION_COUNT_FREQUENT =
+                8; // 1/hr
+        private static final int DEFAULT_MAX_SESSION_COUNT_RARE =
+                3; // .125/hr
+        private static final int DEFAULT_MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW = 20;
+        private static final long DEFAULT_TIMING_SESSION_COALESCING_DURATION_MS = 5000; // 5 seconds
+
+        /** How much time each app will have to run jobs within their standby bucket window. */
+        public long ALLOWED_TIME_PER_PERIOD_MS = DEFAULT_ALLOWED_TIME_PER_PERIOD_MS;
+
+        /**
+         * How much time the package should have before transitioning from out-of-quota to in-quota.
+         * This should not affect processing if the package is already in-quota.
+         */
+        public long IN_QUOTA_BUFFER_MS = DEFAULT_IN_QUOTA_BUFFER_MS;
+
+        /**
+         * The quota window size of the particular standby bucket. Apps in this standby bucket are
+         * expected to run only {@link #ALLOWED_TIME_PER_PERIOD_MS} within the past
+         * WINDOW_SIZE_MS.
+         */
+        public long WINDOW_SIZE_ACTIVE_MS = DEFAULT_WINDOW_SIZE_ACTIVE_MS;
+
+        /**
+         * The quota window size of the particular standby bucket. Apps in this standby bucket are
+         * expected to run only {@link #ALLOWED_TIME_PER_PERIOD_MS} within the past
+         * WINDOW_SIZE_MS.
+         */
+        public long WINDOW_SIZE_WORKING_MS = DEFAULT_WINDOW_SIZE_WORKING_MS;
+
+        /**
+         * The quota window size of the particular standby bucket. Apps in this standby bucket are
+         * expected to run only {@link #ALLOWED_TIME_PER_PERIOD_MS} within the past
+         * WINDOW_SIZE_MS.
+         */
+        public long WINDOW_SIZE_FREQUENT_MS = DEFAULT_WINDOW_SIZE_FREQUENT_MS;
+
+        /**
+         * The quota window size of the particular standby bucket. Apps in this standby bucket are
+         * expected to run only {@link #ALLOWED_TIME_PER_PERIOD_MS} within the past
+         * WINDOW_SIZE_MS.
+         */
+        public long WINDOW_SIZE_RARE_MS = DEFAULT_WINDOW_SIZE_RARE_MS;
+
+        /**
+         * The maximum amount of time an app can have its jobs running within a 24 hour window.
+         */
+        public long MAX_EXECUTION_TIME_MS = DEFAULT_MAX_EXECUTION_TIME_MS;
+
+        /**
+         * The maximum number of jobs an app can run within this particular standby bucket's
+         * window size.
+         */
+        public int MAX_JOB_COUNT_ACTIVE = DEFAULT_MAX_JOB_COUNT_ACTIVE;
+
+        /**
+         * The maximum number of jobs an app can run within this particular standby bucket's
+         * window size.
+         */
+        public int MAX_JOB_COUNT_WORKING = DEFAULT_MAX_JOB_COUNT_WORKING;
+
+        /**
+         * The maximum number of jobs an app can run within this particular standby bucket's
+         * window size.
+         */
+        public int MAX_JOB_COUNT_FREQUENT = DEFAULT_MAX_JOB_COUNT_FREQUENT;
+
+        /**
+         * The maximum number of jobs an app can run within this particular standby bucket's
+         * window size.
+         */
+        public int MAX_JOB_COUNT_RARE = DEFAULT_MAX_JOB_COUNT_RARE;
+
+        /** The period of time used to rate limit recently run jobs. */
+        public long RATE_LIMITING_WINDOW_MS = DEFAULT_RATE_LIMITING_WINDOW_MS;
+
+        /**
+         * The maximum number of jobs that can run within the past {@link #RATE_LIMITING_WINDOW_MS}.
+         */
+        public int MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW =
+                DEFAULT_MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW;
+
+        /**
+         * The maximum number of {@link TimingSession}s an app can run within this particular
+         * standby bucket's window size.
+         */
+        public int MAX_SESSION_COUNT_ACTIVE = DEFAULT_MAX_SESSION_COUNT_ACTIVE;
+
+        /**
+         * The maximum number of {@link TimingSession}s an app can run within this particular
+         * standby bucket's window size.
+         */
+        public int MAX_SESSION_COUNT_WORKING = DEFAULT_MAX_SESSION_COUNT_WORKING;
+
+        /**
+         * The maximum number of {@link TimingSession}s an app can run within this particular
+         * standby bucket's window size.
+         */
+        public int MAX_SESSION_COUNT_FREQUENT = DEFAULT_MAX_SESSION_COUNT_FREQUENT;
+
+        /**
+         * The maximum number of {@link TimingSession}s an app can run within this particular
+         * standby bucket's window size.
+         */
+        public int MAX_SESSION_COUNT_RARE = DEFAULT_MAX_SESSION_COUNT_RARE;
+
+        /**
+         * The maximum number of {@link TimingSession}s that can run within the past
+         * {@link #ALLOWED_TIME_PER_PERIOD_MS}.
+         */
+        public int MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW =
+                DEFAULT_MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW;
+
+        /**
+         * Treat two distinct {@link TimingSession}s as the same if they start and end within this
+         * amount of time of each other.
+         */
+        public long TIMING_SESSION_COALESCING_DURATION_MS =
+                DEFAULT_TIMING_SESSION_COALESCING_DURATION_MS;
+
+        // Safeguards
+
+        /** The minimum number of jobs that any bucket will be allowed to run within its window. */
+        private static final int MIN_BUCKET_JOB_COUNT = 10;
+
+        /**
+         * The minimum number of {@link TimingSession}s that any bucket will be allowed to run
+         * within its window.
+         */
+        private static final int MIN_BUCKET_SESSION_COUNT = 1;
+
+        /** The minimum value that {@link #MAX_EXECUTION_TIME_MS} can have. */
+        private static final long MIN_MAX_EXECUTION_TIME_MS = 60 * MINUTE_IN_MILLIS;
+
+        /** The minimum value that {@link #MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW} can have. */
+        private static final int MIN_MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW = 10;
+
+        /** The minimum value that {@link #MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW} can have. */
+        private static final int MIN_MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW = 10;
+
+        /** The minimum value that {@link #RATE_LIMITING_WINDOW_MS} can have. */
+        private static final long MIN_RATE_LIMITING_WINDOW_MS = 30 * SECOND_IN_MILLIS;
+
+        QcConstants(Handler handler) {
+            super(handler);
+        }
+
+        private void start(ContentResolver resolver) {
+            mResolver = resolver;
+            mResolver.registerContentObserver(Settings.Global.getUriFor(
+                    Settings.Global.JOB_SCHEDULER_QUOTA_CONTROLLER_CONSTANTS), false, this);
+            updateConstants();
+        }
+
+        @Override
+        public void onChange(boolean selfChange, Uri uri) {
+            final String constants = Settings.Global.getString(
+                    mResolver, Settings.Global.JOB_SCHEDULER_QUOTA_CONTROLLER_CONSTANTS);
+
+            try {
+                mParser.setString(constants);
+            } catch (Exception e) {
+                // Failed to parse the settings string, log this and move on with defaults.
+                Slog.e(TAG, "Bad jobscheduler quota controller settings", e);
+            }
+
+            ALLOWED_TIME_PER_PERIOD_MS = mParser.getDurationMillis(
+                    KEY_ALLOWED_TIME_PER_PERIOD_MS, DEFAULT_ALLOWED_TIME_PER_PERIOD_MS);
+            IN_QUOTA_BUFFER_MS = mParser.getDurationMillis(
+                    KEY_IN_QUOTA_BUFFER_MS, DEFAULT_IN_QUOTA_BUFFER_MS);
+            WINDOW_SIZE_ACTIVE_MS = mParser.getDurationMillis(
+                    KEY_WINDOW_SIZE_ACTIVE_MS, DEFAULT_WINDOW_SIZE_ACTIVE_MS);
+            WINDOW_SIZE_WORKING_MS = mParser.getDurationMillis(
+                    KEY_WINDOW_SIZE_WORKING_MS, DEFAULT_WINDOW_SIZE_WORKING_MS);
+            WINDOW_SIZE_FREQUENT_MS = mParser.getDurationMillis(
+                    KEY_WINDOW_SIZE_FREQUENT_MS, DEFAULT_WINDOW_SIZE_FREQUENT_MS);
+            WINDOW_SIZE_RARE_MS = mParser.getDurationMillis(
+                    KEY_WINDOW_SIZE_RARE_MS, DEFAULT_WINDOW_SIZE_RARE_MS);
+            MAX_EXECUTION_TIME_MS = mParser.getDurationMillis(
+                    KEY_MAX_EXECUTION_TIME_MS, DEFAULT_MAX_EXECUTION_TIME_MS);
+            MAX_JOB_COUNT_ACTIVE = mParser.getInt(
+                    KEY_MAX_JOB_COUNT_ACTIVE, DEFAULT_MAX_JOB_COUNT_ACTIVE);
+            MAX_JOB_COUNT_WORKING = mParser.getInt(
+                    KEY_MAX_JOB_COUNT_WORKING, DEFAULT_MAX_JOB_COUNT_WORKING);
+            MAX_JOB_COUNT_FREQUENT = mParser.getInt(
+                    KEY_MAX_JOB_COUNT_FREQUENT, DEFAULT_MAX_JOB_COUNT_FREQUENT);
+            MAX_JOB_COUNT_RARE = mParser.getInt(
+                    KEY_MAX_JOB_COUNT_RARE, DEFAULT_MAX_JOB_COUNT_RARE);
+            RATE_LIMITING_WINDOW_MS = mParser.getLong(
+                    KEY_RATE_LIMITING_WINDOW_MS, DEFAULT_RATE_LIMITING_WINDOW_MS);
+            MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW = mParser.getInt(
+                    KEY_MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW,
+                    DEFAULT_MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW);
+            MAX_SESSION_COUNT_ACTIVE = mParser.getInt(
+                    KEY_MAX_SESSION_COUNT_ACTIVE, DEFAULT_MAX_SESSION_COUNT_ACTIVE);
+            MAX_SESSION_COUNT_WORKING = mParser.getInt(
+                    KEY_MAX_SESSION_COUNT_WORKING, DEFAULT_MAX_SESSION_COUNT_WORKING);
+            MAX_SESSION_COUNT_FREQUENT = mParser.getInt(
+                    KEY_MAX_SESSION_COUNT_FREQUENT, DEFAULT_MAX_SESSION_COUNT_FREQUENT);
+            MAX_SESSION_COUNT_RARE = mParser.getInt(
+                    KEY_MAX_SESSION_COUNT_RARE, DEFAULT_MAX_SESSION_COUNT_RARE);
+            MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW = mParser.getInt(
+                    KEY_MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW,
+                    DEFAULT_MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW);
+            TIMING_SESSION_COALESCING_DURATION_MS = mParser.getLong(
+                    KEY_TIMING_SESSION_COALESCING_DURATION_MS,
+                    DEFAULT_TIMING_SESSION_COALESCING_DURATION_MS);
+
+            updateConstants();
+        }
+
+        @VisibleForTesting
+        void updateConstants() {
+            synchronized (mLock) {
+                boolean changed = false;
+
+                long newMaxExecutionTimeMs = Math.max(MIN_MAX_EXECUTION_TIME_MS,
+                        Math.min(MAX_PERIOD_MS, MAX_EXECUTION_TIME_MS));
+                if (mMaxExecutionTimeMs != newMaxExecutionTimeMs) {
+                    mMaxExecutionTimeMs = newMaxExecutionTimeMs;
+                    mMaxExecutionTimeIntoQuotaMs = mMaxExecutionTimeMs - mQuotaBufferMs;
+                    changed = true;
+                }
+                long newAllowedTimeMs = Math.min(mMaxExecutionTimeMs,
+                        Math.max(MINUTE_IN_MILLIS, ALLOWED_TIME_PER_PERIOD_MS));
+                if (mAllowedTimePerPeriodMs != newAllowedTimeMs) {
+                    mAllowedTimePerPeriodMs = newAllowedTimeMs;
+                    mAllowedTimeIntoQuotaMs = mAllowedTimePerPeriodMs - mQuotaBufferMs;
+                    changed = true;
+                }
+                // Make sure quota buffer is non-negative, not greater than allowed time per period,
+                // and no more than 5 minutes.
+                long newQuotaBufferMs = Math.max(0, Math.min(mAllowedTimePerPeriodMs,
+                        Math.min(5 * MINUTE_IN_MILLIS, IN_QUOTA_BUFFER_MS)));
+                if (mQuotaBufferMs != newQuotaBufferMs) {
+                    mQuotaBufferMs = newQuotaBufferMs;
+                    mAllowedTimeIntoQuotaMs = mAllowedTimePerPeriodMs - mQuotaBufferMs;
+                    mMaxExecutionTimeIntoQuotaMs = mMaxExecutionTimeMs - mQuotaBufferMs;
+                    changed = true;
+                }
+                long newActivePeriodMs = Math.max(mAllowedTimePerPeriodMs,
+                        Math.min(MAX_PERIOD_MS, WINDOW_SIZE_ACTIVE_MS));
+                if (mBucketPeriodsMs[ACTIVE_INDEX] != newActivePeriodMs) {
+                    mBucketPeriodsMs[ACTIVE_INDEX] = newActivePeriodMs;
+                    changed = true;
+                }
+                long newWorkingPeriodMs = Math.max(mAllowedTimePerPeriodMs,
+                        Math.min(MAX_PERIOD_MS, WINDOW_SIZE_WORKING_MS));
+                if (mBucketPeriodsMs[WORKING_INDEX] != newWorkingPeriodMs) {
+                    mBucketPeriodsMs[WORKING_INDEX] = newWorkingPeriodMs;
+                    changed = true;
+                }
+                long newFrequentPeriodMs = Math.max(mAllowedTimePerPeriodMs,
+                        Math.min(MAX_PERIOD_MS, WINDOW_SIZE_FREQUENT_MS));
+                if (mBucketPeriodsMs[FREQUENT_INDEX] != newFrequentPeriodMs) {
+                    mBucketPeriodsMs[FREQUENT_INDEX] = newFrequentPeriodMs;
+                    changed = true;
+                }
+                long newRarePeriodMs = Math.max(mAllowedTimePerPeriodMs,
+                        Math.min(MAX_PERIOD_MS, WINDOW_SIZE_RARE_MS));
+                if (mBucketPeriodsMs[RARE_INDEX] != newRarePeriodMs) {
+                    mBucketPeriodsMs[RARE_INDEX] = newRarePeriodMs;
+                    changed = true;
+                }
+                long newRateLimitingWindowMs = Math.min(MAX_PERIOD_MS,
+                        Math.max(MIN_RATE_LIMITING_WINDOW_MS, RATE_LIMITING_WINDOW_MS));
+                if (mRateLimitingWindowMs != newRateLimitingWindowMs) {
+                    mRateLimitingWindowMs = newRateLimitingWindowMs;
+                    changed = true;
+                }
+                int newMaxJobCountPerRateLimitingWindow = Math.max(
+                        MIN_MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW,
+                        MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW);
+                if (mMaxJobCountPerRateLimitingWindow != newMaxJobCountPerRateLimitingWindow) {
+                    mMaxJobCountPerRateLimitingWindow = newMaxJobCountPerRateLimitingWindow;
+                    changed = true;
+                }
+                int newActiveMaxJobCount = Math.max(MIN_BUCKET_JOB_COUNT, MAX_JOB_COUNT_ACTIVE);
+                if (mMaxBucketJobCounts[ACTIVE_INDEX] != newActiveMaxJobCount) {
+                    mMaxBucketJobCounts[ACTIVE_INDEX] = newActiveMaxJobCount;
+                    changed = true;
+                }
+                int newWorkingMaxJobCount = Math.max(MIN_BUCKET_JOB_COUNT, MAX_JOB_COUNT_WORKING);
+                if (mMaxBucketJobCounts[WORKING_INDEX] != newWorkingMaxJobCount) {
+                    mMaxBucketJobCounts[WORKING_INDEX] = newWorkingMaxJobCount;
+                    changed = true;
+                }
+                int newFrequentMaxJobCount = Math.max(MIN_BUCKET_JOB_COUNT, MAX_JOB_COUNT_FREQUENT);
+                if (mMaxBucketJobCounts[FREQUENT_INDEX] != newFrequentMaxJobCount) {
+                    mMaxBucketJobCounts[FREQUENT_INDEX] = newFrequentMaxJobCount;
+                    changed = true;
+                }
+                int newRareMaxJobCount = Math.max(MIN_BUCKET_JOB_COUNT, MAX_JOB_COUNT_RARE);
+                if (mMaxBucketJobCounts[RARE_INDEX] != newRareMaxJobCount) {
+                    mMaxBucketJobCounts[RARE_INDEX] = newRareMaxJobCount;
+                    changed = true;
+                }
+                int newMaxSessionCountPerRateLimitPeriod = Math.max(
+                        MIN_MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW,
+                        MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW);
+                if (mMaxSessionCountPerRateLimitingWindow != newMaxSessionCountPerRateLimitPeriod) {
+                    mMaxSessionCountPerRateLimitingWindow = newMaxSessionCountPerRateLimitPeriod;
+                    changed = true;
+                }
+                int newActiveMaxSessionCount =
+                        Math.max(MIN_BUCKET_SESSION_COUNT, MAX_SESSION_COUNT_ACTIVE);
+                if (mMaxBucketSessionCounts[ACTIVE_INDEX] != newActiveMaxSessionCount) {
+                    mMaxBucketSessionCounts[ACTIVE_INDEX] = newActiveMaxSessionCount;
+                    changed = true;
+                }
+                int newWorkingMaxSessionCount =
+                        Math.max(MIN_BUCKET_SESSION_COUNT, MAX_SESSION_COUNT_WORKING);
+                if (mMaxBucketSessionCounts[WORKING_INDEX] != newWorkingMaxSessionCount) {
+                    mMaxBucketSessionCounts[WORKING_INDEX] = newWorkingMaxSessionCount;
+                    changed = true;
+                }
+                int newFrequentMaxSessionCount =
+                        Math.max(MIN_BUCKET_SESSION_COUNT, MAX_SESSION_COUNT_FREQUENT);
+                if (mMaxBucketSessionCounts[FREQUENT_INDEX] != newFrequentMaxSessionCount) {
+                    mMaxBucketSessionCounts[FREQUENT_INDEX] = newFrequentMaxSessionCount;
+                    changed = true;
+                }
+                int newRareMaxSessionCount =
+                        Math.max(MIN_BUCKET_SESSION_COUNT, MAX_SESSION_COUNT_RARE);
+                if (mMaxBucketSessionCounts[RARE_INDEX] != newRareMaxSessionCount) {
+                    mMaxBucketSessionCounts[RARE_INDEX] = newRareMaxSessionCount;
+                    changed = true;
+                }
+                long newSessionCoalescingDurationMs = Math.min(15 * MINUTE_IN_MILLIS,
+                        Math.max(0, TIMING_SESSION_COALESCING_DURATION_MS));
+                if (mTimingSessionCoalescingDurationMs != newSessionCoalescingDurationMs) {
+                    mTimingSessionCoalescingDurationMs = newSessionCoalescingDurationMs;
+                    changed = true;
+                }
+
+                if (changed && mShouldThrottle) {
+                    // Update job bookkeeping out of band.
+                    BackgroundThread.getHandler().post(() -> {
+                        synchronized (mLock) {
+                            invalidateAllExecutionStatsLocked();
+                            maybeUpdateAllConstraintsLocked();
+                        }
+                    });
+                }
+            }
+        }
+
+        private void dump(IndentingPrintWriter pw) {
+            pw.println();
+            pw.println("QuotaController:");
+            pw.increaseIndent();
+            pw.printPair(KEY_ALLOWED_TIME_PER_PERIOD_MS, ALLOWED_TIME_PER_PERIOD_MS).println();
+            pw.printPair(KEY_IN_QUOTA_BUFFER_MS, IN_QUOTA_BUFFER_MS).println();
+            pw.printPair(KEY_WINDOW_SIZE_ACTIVE_MS, WINDOW_SIZE_ACTIVE_MS).println();
+            pw.printPair(KEY_WINDOW_SIZE_WORKING_MS, WINDOW_SIZE_WORKING_MS).println();
+            pw.printPair(KEY_WINDOW_SIZE_FREQUENT_MS, WINDOW_SIZE_FREQUENT_MS).println();
+            pw.printPair(KEY_WINDOW_SIZE_RARE_MS, WINDOW_SIZE_RARE_MS).println();
+            pw.printPair(KEY_MAX_EXECUTION_TIME_MS, MAX_EXECUTION_TIME_MS).println();
+            pw.printPair(KEY_MAX_JOB_COUNT_ACTIVE, MAX_JOB_COUNT_ACTIVE).println();
+            pw.printPair(KEY_MAX_JOB_COUNT_WORKING, MAX_JOB_COUNT_WORKING).println();
+            pw.printPair(KEY_MAX_JOB_COUNT_FREQUENT, MAX_JOB_COUNT_FREQUENT).println();
+            pw.printPair(KEY_MAX_JOB_COUNT_RARE, MAX_JOB_COUNT_RARE).println();
+            pw.printPair(KEY_RATE_LIMITING_WINDOW_MS, RATE_LIMITING_WINDOW_MS).println();
+            pw.printPair(KEY_MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW,
+                    MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW).println();
+            pw.printPair(KEY_MAX_SESSION_COUNT_ACTIVE, MAX_SESSION_COUNT_ACTIVE).println();
+            pw.printPair(KEY_MAX_SESSION_COUNT_WORKING, MAX_SESSION_COUNT_WORKING).println();
+            pw.printPair(KEY_MAX_SESSION_COUNT_FREQUENT, MAX_SESSION_COUNT_FREQUENT).println();
+            pw.printPair(KEY_MAX_SESSION_COUNT_RARE, MAX_SESSION_COUNT_RARE).println();
+            pw.printPair(KEY_MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW,
+                    MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW).println();
+            pw.printPair(KEY_TIMING_SESSION_COALESCING_DURATION_MS,
+                    TIMING_SESSION_COALESCING_DURATION_MS).println();
+            pw.decreaseIndent();
+        }
+
+        private void dump(ProtoOutputStream proto) {
+            final long qcToken = proto.start(ConstantsProto.QUOTA_CONTROLLER);
+            proto.write(ConstantsProto.QuotaController.ALLOWED_TIME_PER_PERIOD_MS,
+                    ALLOWED_TIME_PER_PERIOD_MS);
+            proto.write(ConstantsProto.QuotaController.IN_QUOTA_BUFFER_MS, IN_QUOTA_BUFFER_MS);
+            proto.write(ConstantsProto.QuotaController.ACTIVE_WINDOW_SIZE_MS,
+                    WINDOW_SIZE_ACTIVE_MS);
+            proto.write(ConstantsProto.QuotaController.WORKING_WINDOW_SIZE_MS,
+                    WINDOW_SIZE_WORKING_MS);
+            proto.write(ConstantsProto.QuotaController.FREQUENT_WINDOW_SIZE_MS,
+                    WINDOW_SIZE_FREQUENT_MS);
+            proto.write(ConstantsProto.QuotaController.RARE_WINDOW_SIZE_MS, WINDOW_SIZE_RARE_MS);
+            proto.write(ConstantsProto.QuotaController.MAX_EXECUTION_TIME_MS,
+                    MAX_EXECUTION_TIME_MS);
+            proto.write(ConstantsProto.QuotaController.MAX_JOB_COUNT_ACTIVE, MAX_JOB_COUNT_ACTIVE);
+            proto.write(ConstantsProto.QuotaController.MAX_JOB_COUNT_WORKING,
+                    MAX_JOB_COUNT_WORKING);
+            proto.write(ConstantsProto.QuotaController.MAX_JOB_COUNT_FREQUENT,
+                    MAX_JOB_COUNT_FREQUENT);
+            proto.write(ConstantsProto.QuotaController.MAX_JOB_COUNT_RARE, MAX_JOB_COUNT_RARE);
+            proto.write(ConstantsProto.QuotaController.RATE_LIMITING_WINDOW_MS,
+                    RATE_LIMITING_WINDOW_MS);
+            proto.write(ConstantsProto.QuotaController.MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW,
+                    MAX_JOB_COUNT_PER_RATE_LIMITING_WINDOW);
+            proto.write(ConstantsProto.QuotaController.MAX_SESSION_COUNT_ACTIVE,
+                    MAX_SESSION_COUNT_ACTIVE);
+            proto.write(ConstantsProto.QuotaController.MAX_SESSION_COUNT_WORKING,
+                    MAX_SESSION_COUNT_WORKING);
+            proto.write(ConstantsProto.QuotaController.MAX_SESSION_COUNT_FREQUENT,
+                    MAX_SESSION_COUNT_FREQUENT);
+            proto.write(ConstantsProto.QuotaController.MAX_SESSION_COUNT_RARE,
+                    MAX_SESSION_COUNT_RARE);
+            proto.write(ConstantsProto.QuotaController.MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW,
+                    MAX_SESSION_COUNT_PER_RATE_LIMITING_WINDOW);
+            proto.write(ConstantsProto.QuotaController.TIMING_SESSION_COALESCING_DURATION_MS,
+                    TIMING_SESSION_COALESCING_DURATION_MS);
+            proto.end(qcToken);
+        }
+    }
+
+    //////////////////////// TESTING HELPERS /////////////////////////////
+
+    @VisibleForTesting
+    long getAllowedTimePerPeriodMs() {
+        return mAllowedTimePerPeriodMs;
+    }
+
+    @VisibleForTesting
+    @NonNull
+    int[] getBucketMaxJobCounts() {
+        return mMaxBucketJobCounts;
+    }
+
+    @VisibleForTesting
+    @NonNull
+    int[] getBucketMaxSessionCounts() {
+        return mMaxBucketSessionCounts;
+    }
+
+    @VisibleForTesting
+    @NonNull
+    long[] getBucketWindowSizes() {
+        return mBucketPeriodsMs;
+    }
+
+    @VisibleForTesting
+    @NonNull
+    SparseBooleanArray getForegroundUids() {
+        return mForegroundUids;
+    }
+
+    @VisibleForTesting
+    @NonNull
+    Handler getHandler() {
+        return mHandler;
+    }
+
+    @VisibleForTesting
+    long getInQuotaBufferMs() {
+        return mQuotaBufferMs;
+    }
+
+    @VisibleForTesting
+    long getMaxExecutionTimeMs() {
+        return mMaxExecutionTimeMs;
+    }
+
+    @VisibleForTesting
+    int getMaxJobCountPerRateLimitingWindow() {
+        return mMaxJobCountPerRateLimitingWindow;
+    }
+
+    @VisibleForTesting
+    int getMaxSessionCountPerRateLimitingWindow() {
+        return mMaxSessionCountPerRateLimitingWindow;
+    }
+
+    @VisibleForTesting
+    long getRateLimitingWindowMs() {
+        return mRateLimitingWindowMs;
+    }
+
+    @VisibleForTesting
+    long getTimingSessionCoalescingDurationMs() {
+        return mTimingSessionCoalescingDurationMs;
+    }
+
+    @VisibleForTesting
+    @Nullable
+    List<TimingSession> getTimingSessions(int userId, String packageName) {
+        return mTimingSessions.get(userId, packageName);
+    }
+
+    @VisibleForTesting
+    @NonNull
+    QcConstants getQcConstants() {
+        return mQcConstants;
+    }
+
+    //////////////////////////// DATA DUMP //////////////////////////////
+
+    @Override
+    public void dumpControllerStateLocked(final IndentingPrintWriter pw,
+            final Predicate<JobStatus> predicate) {
+        pw.println("Is throttling: " + mShouldThrottle);
+        pw.println("Is charging: " + mChargeTracker.isCharging());
+        pw.println("In parole: " + mInParole);
+        pw.println("Current elapsed time: " + sElapsedRealtimeClock.millis());
+        pw.println();
+
+        pw.print("Foreground UIDs: ");
+        pw.println(mForegroundUids.toString());
+        pw.println();
+
+        pw.println("Cached UID->package map:");
+        pw.increaseIndent();
+        for (int i = 0; i < mUidToPackageCache.size(); ++i) {
+            final int uid = mUidToPackageCache.keyAt(i);
+            pw.print(uid);
+            pw.print(": ");
+            pw.println(mUidToPackageCache.get(uid));
+        }
+        pw.decreaseIndent();
+        pw.println();
+
+        mTrackedJobs.forEach((jobs) -> {
+            for (int j = 0; j < jobs.size(); j++) {
+                final JobStatus js = jobs.valueAt(j);
+                if (!predicate.test(js)) {
+                    continue;
+                }
+                pw.print("#");
+                js.printUniqueId(pw);
+                pw.print(" from ");
+                UserHandle.formatUid(pw, js.getSourceUid());
+                if (mTopStartedJobs.contains(js)) {
+                    pw.print(" (TOP)");
+                }
+                pw.println();
+
+                pw.increaseIndent();
+                pw.print(JobStatus.bucketName(getEffectiveStandbyBucket(js)));
+                pw.print(", ");
+                if (js.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA)) {
+                    pw.print("within quota");
+                } else {
+                    pw.print("not within quota");
+                }
+                pw.print(", ");
+                pw.print(getRemainingExecutionTimeLocked(js));
+                pw.print("ms remaining in quota");
+                pw.decreaseIndent();
+                pw.println();
+            }
+        });
+
+        pw.println();
+        for (int u = 0; u < mPkgTimers.numUsers(); ++u) {
+            final int userId = mPkgTimers.keyAt(u);
+            for (int p = 0; p < mPkgTimers.numPackagesForUser(userId); ++p) {
+                final String pkgName = mPkgTimers.keyAt(u, p);
+                mPkgTimers.valueAt(u, p).dump(pw, predicate);
+                pw.println();
+                List<TimingSession> sessions = mTimingSessions.get(userId, pkgName);
+                if (sessions != null) {
+                    pw.increaseIndent();
+                    pw.println("Saved sessions:");
+                    pw.increaseIndent();
+                    for (int j = sessions.size() - 1; j >= 0; j--) {
+                        TimingSession session = sessions.get(j);
+                        session.dump(pw);
+                    }
+                    pw.decreaseIndent();
+                    pw.decreaseIndent();
+                    pw.println();
+                }
+            }
+        }
+
+        pw.println("Cached execution stats:");
+        pw.increaseIndent();
+        for (int u = 0; u < mExecutionStatsCache.numUsers(); ++u) {
+            final int userId = mExecutionStatsCache.keyAt(u);
+            for (int p = 0; p < mExecutionStatsCache.numPackagesForUser(userId); ++p) {
+                final String pkgName = mExecutionStatsCache.keyAt(u, p);
+                ExecutionStats[] stats = mExecutionStatsCache.valueAt(u, p);
+
+                pw.println(string(userId, pkgName));
+                pw.increaseIndent();
+                for (int i = 0; i < stats.length; ++i) {
+                    ExecutionStats executionStats = stats[i];
+                    if (executionStats != null) {
+                        pw.print(JobStatus.bucketName(i));
+                        pw.print(": ");
+                        pw.println(executionStats);
+                    }
+                }
+                pw.decreaseIndent();
+            }
+        }
+        pw.decreaseIndent();
+
+        pw.println();
+        pw.println("In quota alarms:");
+        pw.increaseIndent();
+        for (int u = 0; u < mInQuotaAlarmListeners.numUsers(); ++u) {
+            final int userId = mInQuotaAlarmListeners.keyAt(u);
+            for (int p = 0; p < mInQuotaAlarmListeners.numPackagesForUser(userId); ++p) {
+                final String pkgName = mInQuotaAlarmListeners.keyAt(u, p);
+                QcAlarmListener alarmListener = mInQuotaAlarmListeners.valueAt(u, p);
+
+                pw.print(string(userId, pkgName));
+                pw.print(": ");
+                if (alarmListener.isWaiting()) {
+                    pw.println(alarmListener.getTriggerTimeElapsed());
+                } else {
+                    pw.println("NOT WAITING");
+                }
+            }
+        }
+        pw.decreaseIndent();
+    }
+
+    @Override
+    public void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+            Predicate<JobStatus> predicate) {
+        final long token = proto.start(fieldId);
+        final long mToken = proto.start(StateControllerProto.QUOTA);
+
+        proto.write(StateControllerProto.QuotaController.IS_CHARGING, mChargeTracker.isCharging());
+        proto.write(StateControllerProto.QuotaController.IS_IN_PAROLE, mInParole);
+        proto.write(StateControllerProto.QuotaController.ELAPSED_REALTIME,
+                sElapsedRealtimeClock.millis());
+
+        for (int i = 0; i < mForegroundUids.size(); ++i) {
+            proto.write(StateControllerProto.QuotaController.FOREGROUND_UIDS,
+                    mForegroundUids.keyAt(i));
+        }
+
+        mTrackedJobs.forEach((jobs) -> {
+            for (int j = 0; j < jobs.size(); j++) {
+                final JobStatus js = jobs.valueAt(j);
+                if (!predicate.test(js)) {
+                    continue;
+                }
+                final long jsToken = proto.start(
+                        StateControllerProto.QuotaController.TRACKED_JOBS);
+                js.writeToShortProto(proto,
+                        StateControllerProto.QuotaController.TrackedJob.INFO);
+                proto.write(StateControllerProto.QuotaController.TrackedJob.SOURCE_UID,
+                        js.getSourceUid());
+                proto.write(
+                        StateControllerProto.QuotaController.TrackedJob.EFFECTIVE_STANDBY_BUCKET,
+                        getEffectiveStandbyBucket(js));
+                proto.write(StateControllerProto.QuotaController.TrackedJob.IS_TOP_STARTED_JOB,
+                        mTopStartedJobs.contains(js));
+                proto.write(StateControllerProto.QuotaController.TrackedJob.HAS_QUOTA,
+                        js.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
+                proto.write(StateControllerProto.QuotaController.TrackedJob.REMAINING_QUOTA_MS,
+                        getRemainingExecutionTimeLocked(js));
+                proto.end(jsToken);
+            }
+        });
+
+        for (int u = 0; u < mPkgTimers.numUsers(); ++u) {
+            final int userId = mPkgTimers.keyAt(u);
+            for (int p = 0; p < mPkgTimers.numPackagesForUser(userId); ++p) {
+                final String pkgName = mPkgTimers.keyAt(u, p);
+                final long psToken = proto.start(
+                        StateControllerProto.QuotaController.PACKAGE_STATS);
+                mPkgTimers.valueAt(u, p).dump(proto,
+                        StateControllerProto.QuotaController.PackageStats.TIMER, predicate);
+
+                List<TimingSession> sessions = mTimingSessions.get(userId, pkgName);
+                if (sessions != null) {
+                    for (int j = sessions.size() - 1; j >= 0; j--) {
+                        TimingSession session = sessions.get(j);
+                        session.dump(proto,
+                                StateControllerProto.QuotaController.PackageStats.SAVED_SESSIONS);
+                    }
+                }
+
+                ExecutionStats[] stats = mExecutionStatsCache.get(userId, pkgName);
+                if (stats != null) {
+                    for (int bucketIndex = 0; bucketIndex < stats.length; ++bucketIndex) {
+                        ExecutionStats es = stats[bucketIndex];
+                        if (es == null) {
+                            continue;
+                        }
+                        final long esToken = proto.start(
+                                StateControllerProto.QuotaController.PackageStats.EXECUTION_STATS);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.STANDBY_BUCKET,
+                                bucketIndex);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.EXPIRATION_TIME_ELAPSED,
+                                es.expirationTimeElapsed);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.WINDOW_SIZE_MS,
+                                es.windowSizeMs);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.JOB_COUNT_LIMIT,
+                                es.jobCountLimit);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.SESSION_COUNT_LIMIT,
+                                es.sessionCountLimit);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.EXECUTION_TIME_IN_WINDOW_MS,
+                                es.executionTimeInWindowMs);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.BG_JOB_COUNT_IN_WINDOW,
+                                es.bgJobCountInWindow);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.EXECUTION_TIME_IN_MAX_PERIOD_MS,
+                                es.executionTimeInMaxPeriodMs);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.BG_JOB_COUNT_IN_MAX_PERIOD,
+                                es.bgJobCountInMaxPeriod);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.SESSION_COUNT_IN_WINDOW,
+                                es.sessionCountInWindow);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.IN_QUOTA_TIME_ELAPSED,
+                                es.inQuotaTimeElapsed);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.JOB_COUNT_EXPIRATION_TIME_ELAPSED,
+                                es.jobRateLimitExpirationTimeElapsed);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.JOB_COUNT_IN_RATE_LIMITING_WINDOW,
+                                es.jobCountInRateLimitingWindow);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.SESSION_COUNT_EXPIRATION_TIME_ELAPSED,
+                                es.sessionRateLimitExpirationTimeElapsed);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.SESSION_COUNT_IN_RATE_LIMITING_WINDOW,
+                                es.sessionCountInRateLimitingWindow);
+                        proto.end(esToken);
+                    }
+                }
+
+                QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, pkgName);
+                if (alarmListener != null) {
+                    final long alToken = proto.start(
+                            StateControllerProto.QuotaController.PackageStats.IN_QUOTA_ALARM_LISTENER);
+                    proto.write(StateControllerProto.QuotaController.AlarmListener.IS_WAITING,
+                            alarmListener.isWaiting());
+                    proto.write(
+                            StateControllerProto.QuotaController.AlarmListener.TRIGGER_TIME_ELAPSED,
+                            alarmListener.getTriggerTimeElapsed());
+                    proto.end(alToken);
+                }
+
+                proto.end(psToken);
+            }
+        }
+
+        proto.end(mToken);
+        proto.end(token);
+    }
+
+    @Override
+    public void dumpConstants(IndentingPrintWriter pw) {
+        mQcConstants.dump(pw);
+    }
+
+    @Override
+    public void dumpConstants(ProtoOutputStream proto) {
+        mQcConstants.dump(proto);
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/StateController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/StateController.java
new file mode 100644
index 0000000..51be38b
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/StateController.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2014 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.controllers;
+
+import static com.android.server.job.JobSchedulerService.DEBUG;
+
+import android.content.Context;
+import android.util.Slog;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.JobSchedulerService.Constants;
+import com.android.server.job.StateChangedListener;
+
+import java.util.function.Predicate;
+
+/**
+ * Incorporates shared controller logic between the various controllers of the JobManager.
+ * These are solely responsible for tracking a list of jobs, and notifying the JM when these
+ * are ready to run, or whether they must be stopped.
+ */
+public abstract class StateController {
+    private static final String TAG = "JobScheduler.SC";
+
+    protected final JobSchedulerService mService;
+    protected final StateChangedListener mStateChangedListener;
+    protected final Context mContext;
+    protected final Object mLock;
+    protected final Constants mConstants;
+
+    StateController(JobSchedulerService service) {
+        mService = service;
+        mStateChangedListener = service;
+        mContext = service.getTestableContext();
+        mLock = service.getLock();
+        mConstants = service.getConstants();
+    }
+
+    /**
+     * Called when the system boot phase has reached
+     * {@link com.android.server.SystemService#PHASE_SYSTEM_SERVICES_READY}.
+     */
+    public void onSystemServicesReady() {
+    }
+
+    /**
+     * Implement the logic here to decide whether a job should be tracked by this controller.
+     * This logic is put here so the JobManager can be completely agnostic of Controller logic.
+     * Also called when updating a task, so implementing controllers have to be aware of
+     * preexisting tasks.
+     */
+    public abstract void maybeStartTrackingJobLocked(JobStatus jobStatus, JobStatus lastJob);
+
+    /**
+     * Optionally implement logic here to prepare the job to be executed.
+     */
+    public void prepareForExecutionLocked(JobStatus jobStatus) {
+    }
+
+    /**
+     * Remove task - this will happen if the task is cancelled, completed, etc.
+     */
+    public abstract void maybeStopTrackingJobLocked(JobStatus jobStatus, JobStatus incomingJob,
+            boolean forUpdate);
+
+    /**
+     * Called when a new job is being created to reschedule an old failed job.
+     */
+    public void rescheduleForFailureLocked(JobStatus newJob, JobStatus failureToReschedule) {
+    }
+
+    /**
+     * Called when the JobScheduler.Constants are updated.
+     */
+    public void onConstantsUpdatedLocked() {
+    }
+
+    /** Called when a package is uninstalled from the device (not for an update). */
+    public void onAppRemovedLocked(String packageName, int uid) {
+    }
+
+    /** Called when a user is removed from the device. */
+    public void onUserRemovedLocked(int userId) {
+    }
+
+    /**
+     * Called when JobSchedulerService has determined that the job is not ready to be run. The
+     * Controller can evaluate if it can or should do something to promote this job's readiness.
+     */
+    public void evaluateStateLocked(JobStatus jobStatus) {
+    }
+
+    /**
+     * Called when something with the UID has changed. The controller should re-evaluate any
+     * internal state tracking dependent on this UID.
+     */
+    public void reevaluateStateLocked(int uid) {
+    }
+
+    protected boolean wouldBeReadyWithConstraintLocked(JobStatus jobStatus, int constraint) {
+        // This is very cheap to check (just a few conditions on data in JobStatus).
+        final boolean jobWouldBeReady = jobStatus.wouldBeReadyWithConstraint(constraint);
+        if (DEBUG) {
+            Slog.v(TAG, "wouldBeReadyWithConstraintLocked: " + jobStatus.toShortString()
+                    + " constraint=" + constraint
+                    + " readyWithConstraint=" + jobWouldBeReady);
+        }
+        if (!jobWouldBeReady) {
+            // If the job wouldn't be ready, nothing to do here.
+            return false;
+        }
+
+        // This is potentially more expensive since JSS may have to query component
+        // presence.
+        return mService.areComponentsInPlaceLocked(jobStatus);
+    }
+
+    public abstract void dumpControllerStateLocked(IndentingPrintWriter pw,
+            Predicate<JobStatus> predicate);
+    public abstract void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+            Predicate<JobStatus> predicate);
+
+    /** Dump any internal constants the Controller may have. */
+    public void dumpConstants(IndentingPrintWriter pw) {
+    }
+
+    /** Dump any internal constants the Controller may have. */
+    public void dumpConstants(ProtoOutputStream proto) {
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/StorageController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/StorageController.java
new file mode 100644
index 0000000..51187df
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/StorageController.java
@@ -0,0 +1,200 @@
+/*
+ * Copyright (C) 2017 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.controllers;
+
+import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.UserHandle;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.Slog;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.StateControllerProto;
+import com.android.server.storage.DeviceStorageMonitorService;
+
+import java.util.function.Predicate;
+
+/**
+ * Simple controller that tracks the status of the device's storage.
+ */
+public final class StorageController extends StateController {
+    private static final String TAG = "JobScheduler.Storage";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG
+            || Log.isLoggable(TAG, Log.DEBUG);
+
+    private final ArraySet<JobStatus> mTrackedTasks = new ArraySet<JobStatus>();
+    private final StorageTracker mStorageTracker;
+
+    @VisibleForTesting
+    public StorageTracker getTracker() {
+        return mStorageTracker;
+    }
+
+    public StorageController(JobSchedulerService service) {
+        super(service);
+        mStorageTracker = new StorageTracker();
+        mStorageTracker.startTracking();
+    }
+
+    @Override
+    public void maybeStartTrackingJobLocked(JobStatus taskStatus, JobStatus lastJob) {
+        if (taskStatus.hasStorageNotLowConstraint()) {
+            mTrackedTasks.add(taskStatus);
+            taskStatus.setTrackingController(JobStatus.TRACKING_STORAGE);
+            taskStatus.setStorageNotLowConstraintSatisfied(mStorageTracker.isStorageNotLow());
+        }
+    }
+
+    @Override
+    public void maybeStopTrackingJobLocked(JobStatus taskStatus, JobStatus incomingJob,
+            boolean forUpdate) {
+        if (taskStatus.clearTrackingController(JobStatus.TRACKING_STORAGE)) {
+            mTrackedTasks.remove(taskStatus);
+        }
+    }
+
+    private void maybeReportNewStorageState() {
+        final boolean storageNotLow = mStorageTracker.isStorageNotLow();
+        boolean reportChange = false;
+        synchronized (mLock) {
+            for (int i = mTrackedTasks.size() - 1; i >= 0; i--) {
+                final JobStatus ts = mTrackedTasks.valueAt(i);
+                reportChange |= ts.setStorageNotLowConstraintSatisfied(storageNotLow);
+            }
+        }
+        if (storageNotLow) {
+            // Tell the scheduler that any ready jobs should be flushed.
+            mStateChangedListener.onRunJobNow(null);
+        } else if (reportChange) {
+            // Let the scheduler know that state has changed. This may or may not result in an
+            // execution.
+            mStateChangedListener.onControllerStateChanged();
+        }
+    }
+
+    public final class StorageTracker extends BroadcastReceiver {
+        /**
+         * Track whether storage is low.
+         */
+        private boolean mStorageLow;
+        /** Sequence number of last broadcast. */
+        private int mLastStorageSeq = -1;
+
+        public StorageTracker() {
+        }
+
+        public void startTracking() {
+            IntentFilter filter = new IntentFilter();
+
+            // Storage status.  Just need to register, since STORAGE_LOW is a sticky
+            // broadcast we will receive that if it is currently active.
+            filter.addAction(Intent.ACTION_DEVICE_STORAGE_LOW);
+            filter.addAction(Intent.ACTION_DEVICE_STORAGE_OK);
+            mContext.registerReceiver(this, filter);
+        }
+
+        public boolean isStorageNotLow() {
+            return !mStorageLow;
+        }
+
+        public int getSeq() {
+            return mLastStorageSeq;
+        }
+
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            onReceiveInternal(intent);
+        }
+
+        @VisibleForTesting
+        public void onReceiveInternal(Intent intent) {
+            final String action = intent.getAction();
+            mLastStorageSeq = intent.getIntExtra(DeviceStorageMonitorService.EXTRA_SEQUENCE,
+                    mLastStorageSeq);
+            if (Intent.ACTION_DEVICE_STORAGE_LOW.equals(action)) {
+                if (DEBUG) {
+                    Slog.d(TAG, "Available storage too low to do work. @ "
+                            + sElapsedRealtimeClock.millis());
+                }
+                mStorageLow = true;
+                maybeReportNewStorageState();
+            } else if (Intent.ACTION_DEVICE_STORAGE_OK.equals(action)) {
+                if (DEBUG) {
+                    Slog.d(TAG, "Available storage high enough to do work. @ "
+                            + sElapsedRealtimeClock.millis());
+                }
+                mStorageLow = false;
+                maybeReportNewStorageState();
+            }
+        }
+    }
+
+    @Override
+    public void dumpControllerStateLocked(IndentingPrintWriter pw,
+            Predicate<JobStatus> predicate) {
+        pw.println("Not low: " + mStorageTracker.isStorageNotLow());
+        pw.println("Sequence: " + mStorageTracker.getSeq());
+        pw.println();
+
+        for (int i = 0; i < mTrackedTasks.size(); i++) {
+            final JobStatus js = mTrackedTasks.valueAt(i);
+            if (!predicate.test(js)) {
+                continue;
+            }
+            pw.print("#");
+            js.printUniqueId(pw);
+            pw.print(" from ");
+            UserHandle.formatUid(pw, js.getSourceUid());
+            pw.println();
+        }
+    }
+
+    @Override
+    public void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+            Predicate<JobStatus> predicate) {
+        final long token = proto.start(fieldId);
+        final long mToken = proto.start(StateControllerProto.STORAGE);
+
+        proto.write(StateControllerProto.StorageController.IS_STORAGE_NOT_LOW,
+                mStorageTracker.isStorageNotLow());
+        proto.write(StateControllerProto.StorageController.LAST_BROADCAST_SEQUENCE_NUMBER,
+                mStorageTracker.getSeq());
+
+        for (int i = 0; i < mTrackedTasks.size(); i++) {
+            final JobStatus js = mTrackedTasks.valueAt(i);
+            if (!predicate.test(js)) {
+                continue;
+            }
+            final long jsToken = proto.start(StateControllerProto.StorageController.TRACKED_JOBS);
+            js.writeToShortProto(proto, StateControllerProto.StorageController.TrackedJob.INFO);
+            proto.write(StateControllerProto.StorageController.TrackedJob.SOURCE_UID,
+                    js.getSourceUid());
+            proto.end(jsToken);
+        }
+
+        proto.end(mToken);
+        proto.end(token);
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/TimeController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/TimeController.java
new file mode 100644
index 0000000..4c11947
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/TimeController.java
@@ -0,0 +1,504 @@
+/*
+ * Copyright (C) 2014 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.controllers;
+
+import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock;
+
+import android.annotation.Nullable;
+import android.app.AlarmManager;
+import android.app.AlarmManager.OnAlarmListener;
+import android.content.Context;
+import android.os.Process;
+import android.os.UserHandle;
+import android.os.WorkSource;
+import android.util.Log;
+import android.util.Slog;
+import android.util.TimeUtils;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.StateControllerProto;
+
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.function.Predicate;
+
+/**
+ * This class sets an alarm for the next expiring job, and determines whether a job's minimum
+ * delay has been satisfied.
+ */
+public final class TimeController extends StateController {
+    private static final String TAG = "JobScheduler.Time";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG
+            || Log.isLoggable(TAG, Log.DEBUG);
+
+    /** Deadline alarm tag for logging purposes */
+    private final String DEADLINE_TAG = "*job.deadline*";
+    /** Delay alarm tag for logging purposes */
+    private final String DELAY_TAG = "*job.delay*";
+
+    private long mNextJobExpiredElapsedMillis;
+    private long mNextDelayExpiredElapsedMillis;
+
+    private final boolean mChainedAttributionEnabled;
+
+    private AlarmManager mAlarmService = null;
+    /** List of tracked jobs, sorted asc. by deadline */
+    private final List<JobStatus> mTrackedJobs = new LinkedList<>();
+
+    public TimeController(JobSchedulerService service) {
+        super(service);
+
+        mNextJobExpiredElapsedMillis = Long.MAX_VALUE;
+        mNextDelayExpiredElapsedMillis = Long.MAX_VALUE;
+        mChainedAttributionEnabled = mService.isChainedAttributionEnabled();
+    }
+
+    /**
+     * Check if the job has a timing constraint, and if so determine where to insert it in our
+     * list.
+     */
+    @Override
+    public void maybeStartTrackingJobLocked(JobStatus job, JobStatus lastJob) {
+        if (job.hasTimingDelayConstraint() || job.hasDeadlineConstraint()) {
+            maybeStopTrackingJobLocked(job, null, false);
+
+            // First: check the constraints now, because if they are already satisfied
+            // then there is no need to track it.  This gives us a fast path for a common
+            // pattern of having a job with a 0 deadline constraint ("run immediately").
+            // Unlike most controllers, once one of our constraints has been satisfied, it
+            // will never be unsatisfied (our time base can not go backwards).
+            final long nowElapsedMillis = sElapsedRealtimeClock.millis();
+            if (job.hasDeadlineConstraint() && evaluateDeadlineConstraint(job, nowElapsedMillis)) {
+                return;
+            } else if (job.hasTimingDelayConstraint() && evaluateTimingDelayConstraint(job,
+                    nowElapsedMillis)) {
+                if (!job.hasDeadlineConstraint()) {
+                    // If it doesn't have a deadline, we'll never have to touch it again.
+                    return;
+                }
+            }
+
+            boolean isInsert = false;
+            ListIterator<JobStatus> it = mTrackedJobs.listIterator(mTrackedJobs.size());
+            while (it.hasPrevious()) {
+                JobStatus ts = it.previous();
+                if (ts.getLatestRunTimeElapsed() < job.getLatestRunTimeElapsed()) {
+                    // Insert
+                    isInsert = true;
+                    break;
+                }
+            }
+            if (isInsert) {
+                it.next();
+            }
+            it.add(job);
+
+            job.setTrackingController(JobStatus.TRACKING_TIME);
+            WorkSource ws = deriveWorkSource(job.getSourceUid(), job.getSourcePackageName());
+
+            // Only update alarms if the job would be ready with the relevant timing constraint
+            // satisfied.
+            if (job.hasTimingDelayConstraint()
+                    && wouldBeReadyWithConstraintLocked(job, JobStatus.CONSTRAINT_TIMING_DELAY)) {
+                maybeUpdateDelayAlarmLocked(job.getEarliestRunTime(), ws);
+            }
+            if (job.hasDeadlineConstraint()
+                    && wouldBeReadyWithConstraintLocked(job, JobStatus.CONSTRAINT_DEADLINE)) {
+                maybeUpdateDeadlineAlarmLocked(job.getLatestRunTimeElapsed(), ws);
+            }
+        }
+    }
+
+    /**
+     * When we stop tracking a job, we only need to update our alarms if the job we're no longer
+     * tracking was the one our alarms were based off of.
+     */
+    @Override
+    public void maybeStopTrackingJobLocked(JobStatus job, JobStatus incomingJob,
+            boolean forUpdate) {
+        if (job.clearTrackingController(JobStatus.TRACKING_TIME)) {
+            if (mTrackedJobs.remove(job)) {
+                checkExpiredDelaysAndResetAlarm();
+                checkExpiredDeadlinesAndResetAlarm();
+            }
+        }
+    }
+
+    @Override
+    public void evaluateStateLocked(JobStatus job) {
+        final long nowElapsedMillis = sElapsedRealtimeClock.millis();
+
+        // Check deadline constraint first because if it's satisfied, we avoid a little bit of
+        // unnecessary processing of the timing delay.
+        if (job.hasDeadlineConstraint()
+                && !job.isConstraintSatisfied(JobStatus.CONSTRAINT_DEADLINE)
+                && job.getLatestRunTimeElapsed() <= mNextJobExpiredElapsedMillis) {
+            if (evaluateDeadlineConstraint(job, nowElapsedMillis)) {
+                checkExpiredDeadlinesAndResetAlarm();
+                checkExpiredDelaysAndResetAlarm();
+            } else {
+                final boolean isAlarmForJob =
+                        job.getLatestRunTimeElapsed() == mNextJobExpiredElapsedMillis;
+                final boolean wouldBeReady = wouldBeReadyWithConstraintLocked(
+                        job, JobStatus.CONSTRAINT_DEADLINE);
+                if ((isAlarmForJob && !wouldBeReady) || (!isAlarmForJob && wouldBeReady)) {
+                    checkExpiredDeadlinesAndResetAlarm();
+                }
+            }
+        }
+        if (job.hasTimingDelayConstraint()
+                && !job.isConstraintSatisfied(JobStatus.CONSTRAINT_TIMING_DELAY)
+                && job.getEarliestRunTime() <= mNextDelayExpiredElapsedMillis) {
+            if (evaluateTimingDelayConstraint(job, nowElapsedMillis)) {
+                checkExpiredDelaysAndResetAlarm();
+            } else {
+                final boolean isAlarmForJob =
+                        job.getEarliestRunTime() == mNextDelayExpiredElapsedMillis;
+                final boolean wouldBeReady = wouldBeReadyWithConstraintLocked(
+                        job, JobStatus.CONSTRAINT_TIMING_DELAY);
+                if ((isAlarmForJob && !wouldBeReady) || (!isAlarmForJob && wouldBeReady)) {
+                    checkExpiredDelaysAndResetAlarm();
+                }
+            }
+        }
+    }
+
+    @Override
+    public void reevaluateStateLocked(int uid) {
+        checkExpiredDeadlinesAndResetAlarm();
+        checkExpiredDelaysAndResetAlarm();
+    }
+
+    /**
+     * Determines whether this controller can stop tracking the given job.
+     * The controller is no longer interested in a job once its time constraint is satisfied, and
+     * the job's deadline is fulfilled - unlike other controllers a time constraint can't toggle
+     * back and forth.
+     */
+    private boolean canStopTrackingJobLocked(JobStatus job) {
+        return (!job.hasTimingDelayConstraint()
+                        || job.isConstraintSatisfied(JobStatus.CONSTRAINT_TIMING_DELAY))
+                && (!job.hasDeadlineConstraint()
+                        || job.isConstraintSatisfied(JobStatus.CONSTRAINT_DEADLINE));
+    }
+
+    private void ensureAlarmServiceLocked() {
+        if (mAlarmService == null) {
+            mAlarmService = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
+        }
+    }
+
+    /**
+     * Checks list of jobs for ones that have an expired deadline, sending them to the JobScheduler
+     * if so, removing them from this list, and updating the alarm for the next expiry time.
+     */
+    @VisibleForTesting
+    void checkExpiredDeadlinesAndResetAlarm() {
+        synchronized (mLock) {
+            long nextExpiryTime = Long.MAX_VALUE;
+            int nextExpiryUid = 0;
+            String nextExpiryPackageName = null;
+            final long nowElapsedMillis = sElapsedRealtimeClock.millis();
+
+            ListIterator<JobStatus> it = mTrackedJobs.listIterator();
+            while (it.hasNext()) {
+                JobStatus job = it.next();
+                if (!job.hasDeadlineConstraint()) {
+                    continue;
+                }
+
+                if (evaluateDeadlineConstraint(job, nowElapsedMillis)) {
+                    if (job.isReady()) {
+                        // If the job still isn't ready, there's no point trying to rush the
+                        // Scheduler.
+                        mStateChangedListener.onRunJobNow(job);
+                    }
+                    it.remove();
+                } else {  // Sorted by expiry time, so take the next one and stop.
+                    if (!wouldBeReadyWithConstraintLocked(job, JobStatus.CONSTRAINT_DEADLINE)) {
+                        if (DEBUG) {
+                            Slog.i(TAG,
+                                    "Skipping " + job + " because deadline won't make it ready.");
+                        }
+                        continue;
+                    }
+                    nextExpiryTime = job.getLatestRunTimeElapsed();
+                    nextExpiryUid = job.getSourceUid();
+                    nextExpiryPackageName = job.getSourcePackageName();
+                    break;
+                }
+            }
+            setDeadlineExpiredAlarmLocked(nextExpiryTime,
+                    deriveWorkSource(nextExpiryUid, nextExpiryPackageName));
+        }
+    }
+
+    /** @return true if the job's deadline constraint is satisfied */
+    private boolean evaluateDeadlineConstraint(JobStatus job, long nowElapsedMillis) {
+        final long jobDeadline = job.getLatestRunTimeElapsed();
+
+        if (jobDeadline <= nowElapsedMillis) {
+            if (job.hasTimingDelayConstraint()) {
+                job.setTimingDelayConstraintSatisfied(true);
+            }
+            job.setDeadlineConstraintSatisfied(true);
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Handles alarm that notifies us that a job's delay has expired. Iterates through the list of
+     * tracked jobs and marks them as ready as appropriate.
+     */
+    @VisibleForTesting
+    void checkExpiredDelaysAndResetAlarm() {
+        synchronized (mLock) {
+            final long nowElapsedMillis = sElapsedRealtimeClock.millis();
+            long nextDelayTime = Long.MAX_VALUE;
+            int nextDelayUid = 0;
+            String nextDelayPackageName = null;
+            boolean ready = false;
+            Iterator<JobStatus> it = mTrackedJobs.iterator();
+            while (it.hasNext()) {
+                final JobStatus job = it.next();
+                if (!job.hasTimingDelayConstraint()) {
+                    continue;
+                }
+                if (evaluateTimingDelayConstraint(job, nowElapsedMillis)) {
+                    if (canStopTrackingJobLocked(job)) {
+                        it.remove();
+                    }
+                    if (job.isReady()) {
+                        ready = true;
+                    }
+                } else {
+                    if (!wouldBeReadyWithConstraintLocked(job, JobStatus.CONSTRAINT_TIMING_DELAY)) {
+                        if (DEBUG) {
+                            Slog.i(TAG,
+                                    "Skipping " + job + " because delay won't make it ready.");
+                        }
+                        continue;
+                    }
+                    // If this job still doesn't have its delay constraint satisfied,
+                    // then see if it is the next upcoming delay time for the alarm.
+                    final long jobDelayTime = job.getEarliestRunTime();
+                    if (nextDelayTime > jobDelayTime) {
+                        nextDelayTime = jobDelayTime;
+                        nextDelayUid = job.getSourceUid();
+                        nextDelayPackageName = job.getSourcePackageName();
+                    }
+                }
+            }
+            if (ready) {
+                mStateChangedListener.onControllerStateChanged();
+            }
+            setDelayExpiredAlarmLocked(nextDelayTime,
+                    deriveWorkSource(nextDelayUid, nextDelayPackageName));
+        }
+    }
+
+    private WorkSource deriveWorkSource(int uid, @Nullable String packageName) {
+        if (mChainedAttributionEnabled) {
+            WorkSource ws = new WorkSource();
+            ws.createWorkChain()
+                    .addNode(uid, packageName)
+                    .addNode(Process.SYSTEM_UID, "JobScheduler");
+            return ws;
+        } else {
+            return packageName == null ? new WorkSource(uid) : new WorkSource(uid, packageName);
+        }
+    }
+
+    /** @return true if the job's delay constraint is satisfied */
+    private boolean evaluateTimingDelayConstraint(JobStatus job, long nowElapsedMillis) {
+        final long jobDelayTime = job.getEarliestRunTime();
+        if (jobDelayTime <= nowElapsedMillis) {
+            job.setTimingDelayConstraintSatisfied(true);
+            return true;
+        }
+        return false;
+    }
+
+    private void maybeUpdateDelayAlarmLocked(long delayExpiredElapsed, WorkSource ws) {
+        if (delayExpiredElapsed < mNextDelayExpiredElapsedMillis) {
+            setDelayExpiredAlarmLocked(delayExpiredElapsed, ws);
+        }
+    }
+
+    private void maybeUpdateDeadlineAlarmLocked(long deadlineExpiredElapsed, WorkSource ws) {
+        if (deadlineExpiredElapsed < mNextJobExpiredElapsedMillis) {
+            setDeadlineExpiredAlarmLocked(deadlineExpiredElapsed, ws);
+        }
+    }
+
+    /**
+     * Set an alarm with the {@link android.app.AlarmManager} for the next time at which a job's
+     * delay will expire.
+     * This alarm <b>will</b> wake up the phone.
+     */
+    private void setDelayExpiredAlarmLocked(long alarmTimeElapsedMillis, WorkSource ws) {
+        alarmTimeElapsedMillis = maybeAdjustAlarmTime(alarmTimeElapsedMillis);
+        if (mNextDelayExpiredElapsedMillis == alarmTimeElapsedMillis) {
+            return;
+        }
+        mNextDelayExpiredElapsedMillis = alarmTimeElapsedMillis;
+        updateAlarmWithListenerLocked(DELAY_TAG, mNextDelayExpiredListener,
+                mNextDelayExpiredElapsedMillis, ws);
+    }
+
+    /**
+     * Set an alarm with the {@link android.app.AlarmManager} for the next time at which a job's
+     * deadline will expire.
+     * This alarm <b>will</b> wake up the phone.
+     */
+    private void setDeadlineExpiredAlarmLocked(long alarmTimeElapsedMillis, WorkSource ws) {
+        alarmTimeElapsedMillis = maybeAdjustAlarmTime(alarmTimeElapsedMillis);
+        if (mNextJobExpiredElapsedMillis == alarmTimeElapsedMillis) {
+            return;
+        }
+        mNextJobExpiredElapsedMillis = alarmTimeElapsedMillis;
+        updateAlarmWithListenerLocked(DEADLINE_TAG, mDeadlineExpiredListener,
+                mNextJobExpiredElapsedMillis, ws);
+    }
+
+    private long maybeAdjustAlarmTime(long proposedAlarmTimeElapsedMillis) {
+        return Math.max(proposedAlarmTimeElapsedMillis, sElapsedRealtimeClock.millis());
+    }
+
+    private void updateAlarmWithListenerLocked(String tag, OnAlarmListener listener,
+            long alarmTimeElapsed, WorkSource ws) {
+        ensureAlarmServiceLocked();
+        if (alarmTimeElapsed == Long.MAX_VALUE) {
+            mAlarmService.cancel(listener);
+        } else {
+            if (DEBUG) {
+                Slog.d(TAG, "Setting " + tag + " for: " + alarmTimeElapsed);
+            }
+            mAlarmService.set(AlarmManager.ELAPSED_REALTIME_WAKEUP, alarmTimeElapsed,
+                    AlarmManager.WINDOW_HEURISTIC, 0, tag, listener, null, ws);
+        }
+    }
+
+    // Job/delay expiration alarm handling
+
+    private final OnAlarmListener mDeadlineExpiredListener = new OnAlarmListener() {
+        @Override
+        public void onAlarm() {
+            if (DEBUG) {
+                Slog.d(TAG, "Deadline-expired alarm fired");
+            }
+            checkExpiredDeadlinesAndResetAlarm();
+        }
+    };
+
+    private final OnAlarmListener mNextDelayExpiredListener = new OnAlarmListener() {
+        @Override
+        public void onAlarm() {
+            if (DEBUG) {
+                Slog.d(TAG, "Delay-expired alarm fired");
+            }
+            checkExpiredDelaysAndResetAlarm();
+        }
+    };
+
+    @VisibleForTesting
+    void recheckAlarmsLocked() {
+        checkExpiredDeadlinesAndResetAlarm();
+        checkExpiredDelaysAndResetAlarm();
+    }
+
+    @Override
+    public void dumpControllerStateLocked(IndentingPrintWriter pw,
+            Predicate<JobStatus> predicate) {
+        final long nowElapsed = sElapsedRealtimeClock.millis();
+        pw.println("Elapsed clock: " + nowElapsed);
+
+        pw.print("Next delay alarm in ");
+        TimeUtils.formatDuration(mNextDelayExpiredElapsedMillis, nowElapsed, pw);
+        pw.println();
+        pw.print("Next deadline alarm in ");
+        TimeUtils.formatDuration(mNextJobExpiredElapsedMillis, nowElapsed, pw);
+        pw.println();
+        pw.println();
+
+        for (JobStatus ts : mTrackedJobs) {
+            if (!predicate.test(ts)) {
+                continue;
+            }
+            pw.print("#");
+            ts.printUniqueId(pw);
+            pw.print(" from ");
+            UserHandle.formatUid(pw, ts.getSourceUid());
+            pw.print(": Delay=");
+            if (ts.hasTimingDelayConstraint()) {
+                TimeUtils.formatDuration(ts.getEarliestRunTime(), nowElapsed, pw);
+            } else {
+                pw.print("N/A");
+            }
+            pw.print(", Deadline=");
+            if (ts.hasDeadlineConstraint()) {
+                TimeUtils.formatDuration(ts.getLatestRunTimeElapsed(), nowElapsed, pw);
+            } else {
+                pw.print("N/A");
+            }
+            pw.println();
+        }
+    }
+
+    @Override
+    public void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+            Predicate<JobStatus> predicate) {
+        final long token = proto.start(fieldId);
+        final long mToken = proto.start(StateControllerProto.TIME);
+
+        final long nowElapsed = sElapsedRealtimeClock.millis();
+        proto.write(StateControllerProto.TimeController.NOW_ELAPSED_REALTIME, nowElapsed);
+        proto.write(StateControllerProto.TimeController.TIME_UNTIL_NEXT_DELAY_ALARM_MS,
+                mNextDelayExpiredElapsedMillis - nowElapsed);
+        proto.write(StateControllerProto.TimeController.TIME_UNTIL_NEXT_DEADLINE_ALARM_MS,
+                mNextJobExpiredElapsedMillis - nowElapsed);
+
+        for (JobStatus ts : mTrackedJobs) {
+            if (!predicate.test(ts)) {
+                continue;
+            }
+            final long tsToken = proto.start(StateControllerProto.TimeController.TRACKED_JOBS);
+            ts.writeToShortProto(proto, StateControllerProto.TimeController.TrackedJob.INFO);
+
+            proto.write(StateControllerProto.TimeController.TrackedJob.HAS_TIMING_DELAY_CONSTRAINT,
+                    ts.hasTimingDelayConstraint());
+            proto.write(StateControllerProto.TimeController.TrackedJob.DELAY_TIME_REMAINING_MS,
+                    ts.getEarliestRunTime() - nowElapsed);
+
+            proto.write(StateControllerProto.TimeController.TrackedJob.HAS_DEADLINE_CONSTRAINT,
+                    ts.hasDeadlineConstraint());
+            proto.write(StateControllerProto.TimeController.TrackedJob.TIME_REMAINING_UNTIL_DEADLINE_MS,
+                    ts.getLatestRunTimeElapsed() - nowElapsed);
+
+            proto.end(tsToken);
+        }
+
+        proto.end(mToken);
+        proto.end(token);
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/CarIdlenessTracker.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/CarIdlenessTracker.java
new file mode 100644
index 0000000..82c33f5
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/CarIdlenessTracker.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2018 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.controllers.idle;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.util.Log;
+import android.util.Slog;
+
+import com.android.server.am.ActivityManagerService;
+import com.android.server.job.JobSchedulerService;
+
+import java.io.PrintWriter;
+
+public final class CarIdlenessTracker extends BroadcastReceiver implements IdlenessTracker {
+    private static final String TAG = "JobScheduler.CarIdlenessTracker";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG
+            || Log.isLoggable(TAG, Log.DEBUG);
+
+    public static final String ACTION_GARAGE_MODE_ON =
+            "com.android.server.jobscheduler.GARAGE_MODE_ON";
+    public static final String ACTION_GARAGE_MODE_OFF =
+            "com.android.server.jobscheduler.GARAGE_MODE_OFF";
+
+    public static final String ACTION_FORCE_IDLE = "com.android.server.jobscheduler.FORCE_IDLE";
+    public static final String ACTION_UNFORCE_IDLE = "com.android.server.jobscheduler.UNFORCE_IDLE";
+
+    // After construction, mutations of idle/screen-on state will only happen
+    // on the main looper thread, either in onReceive() or in an alarm callback.
+    private boolean mIdle;
+    private boolean mGarageModeOn;
+    private boolean mForced;
+    private IdlenessListener mIdleListener;
+
+    public CarIdlenessTracker() {
+        // At boot we presume that the user has just "interacted" with the
+        // device in some meaningful way.
+        mIdle = false;
+        mGarageModeOn = false;
+        mForced = false;
+    }
+
+    @Override
+    public boolean isIdle() {
+        return mIdle;
+    }
+
+    @Override
+    public void startTracking(Context context, IdlenessListener listener) {
+        mIdleListener = listener;
+
+        IntentFilter filter = new IntentFilter();
+
+        // Screen state
+        filter.addAction(Intent.ACTION_SCREEN_ON);
+
+        // State of GarageMode
+        filter.addAction(ACTION_GARAGE_MODE_ON);
+        filter.addAction(ACTION_GARAGE_MODE_OFF);
+
+        // Debugging/instrumentation
+        filter.addAction(ACTION_FORCE_IDLE);
+        filter.addAction(ACTION_UNFORCE_IDLE);
+        filter.addAction(ActivityManagerService.ACTION_TRIGGER_IDLE);
+
+        context.registerReceiver(this, filter);
+    }
+
+    @Override
+    public void dump(PrintWriter pw) {
+        pw.print("  mIdle: "); pw.println(mIdle);
+        pw.print("  mGarageModeOn: "); pw.println(mGarageModeOn);
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        final String action = intent.getAction();
+        logIfDebug("Received action: " + action);
+
+        // Check for forced actions
+        if (action.equals(ACTION_FORCE_IDLE)) {
+            logIfDebug("Forcing idle...");
+            setForceIdleState(true);
+        } else if (action.equals(ACTION_UNFORCE_IDLE)) {
+            logIfDebug("Unforcing idle...");
+            setForceIdleState(false);
+        } else if (action.equals(Intent.ACTION_SCREEN_ON)) {
+            logIfDebug("Screen is on...");
+            handleScreenOn();
+        } else if (action.equals(ACTION_GARAGE_MODE_ON)) {
+            logIfDebug("GarageMode is on...");
+            mGarageModeOn = true;
+            updateIdlenessState();
+        } else if (action.equals(ACTION_GARAGE_MODE_OFF)) {
+            logIfDebug("GarageMode is off...");
+            mGarageModeOn = false;
+            updateIdlenessState();
+        } else if (action.equals(ActivityManagerService.ACTION_TRIGGER_IDLE)) {
+            if (!mGarageModeOn) {
+                logIfDebug("Idle trigger fired...");
+                triggerIdlenessOnce();
+            } else {
+                logIfDebug("TRIGGER_IDLE received but not changing state; idle="
+                        + mIdle + " screen=" + mGarageModeOn);
+            }
+        }
+    }
+
+    private void setForceIdleState(boolean forced) {
+        mForced = forced;
+        updateIdlenessState();
+    }
+
+    private void updateIdlenessState() {
+        final boolean newState = (mForced || mGarageModeOn);
+        if (mIdle != newState) {
+            // State of idleness changed. Notifying idleness controller
+            logIfDebug("Device idleness changed. New idle=" + newState);
+            mIdle = newState;
+            mIdleListener.reportNewIdleState(mIdle);
+        } else {
+            // Nothing changed, device idleness is in the same state as new state
+            logIfDebug("Device idleness is the same. Current idle=" + newState);
+        }
+    }
+
+    private void triggerIdlenessOnce() {
+        // This is simply triggering idleness once until some constraint will switch it back off
+        if (mIdle) {
+            // Already in idle state. Nothing to do
+            logIfDebug("Device is already idle");
+        } else {
+            // Going idle once
+            logIfDebug("Device is going idle once");
+            mIdle = true;
+            mIdleListener.reportNewIdleState(mIdle);
+        }
+    }
+
+    private void handleScreenOn() {
+        if (mForced || mGarageModeOn) {
+            // Even though screen is on, the device remains idle
+            logIfDebug("Screen is on, but device cannot exit idle");
+        } else if (mIdle) {
+            // Exiting idle
+            logIfDebug("Device is exiting idle");
+            mIdle = false;
+        } else {
+            // Already in non-idle state. Nothing to do
+            logIfDebug("Device is already non-idle");
+        }
+    }
+
+    private static void logIfDebug(String msg) {
+        if (DEBUG) {
+            Slog.v(TAG, msg);
+        }
+    }
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/DeviceIdlenessTracker.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/DeviceIdlenessTracker.java
new file mode 100644
index 0000000..a85bd40
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/DeviceIdlenessTracker.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2018 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.controllers.idle;
+
+import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock;
+
+import android.app.AlarmManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+
+import android.util.Log;
+import android.util.Slog;
+import com.android.server.am.ActivityManagerService;
+import com.android.server.job.JobSchedulerService;
+
+import java.io.PrintWriter;
+
+public final class DeviceIdlenessTracker extends BroadcastReceiver implements IdlenessTracker {
+    private static final String TAG = "JobScheduler.DeviceIdlenessTracker";
+    private static final boolean DEBUG = JobSchedulerService.DEBUG
+            || Log.isLoggable(TAG, Log.DEBUG);
+
+    private AlarmManager mAlarm;
+
+    // After construction, mutations of idle/screen-on state will only happen
+    // on the main looper thread, either in onReceive() or in an alarm callback.
+    private long mInactivityIdleThreshold;
+    private long mIdleWindowSlop;
+    private boolean mIdle;
+    private boolean mScreenOn;
+    private boolean mDockIdle;
+    private IdlenessListener mIdleListener;
+
+    private AlarmManager.OnAlarmListener mIdleAlarmListener = () -> {
+        handleIdleTrigger();
+    };
+
+    public DeviceIdlenessTracker() {
+        // At boot we presume that the user has just "interacted" with the
+        // device in some meaningful way.
+        mIdle = false;
+        mScreenOn = true;
+        mDockIdle = false;
+    }
+
+    @Override
+    public boolean isIdle() {
+        return mIdle;
+    }
+
+    @Override
+    public void startTracking(Context context, IdlenessListener listener) {
+        mIdleListener = listener;
+        mInactivityIdleThreshold = context.getResources().getInteger(
+                com.android.internal.R.integer.config_jobSchedulerInactivityIdleThreshold);
+        mIdleWindowSlop = context.getResources().getInteger(
+                com.android.internal.R.integer.config_jobSchedulerIdleWindowSlop);
+        mAlarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
+
+        IntentFilter filter = new IntentFilter();
+
+        // Screen state
+        filter.addAction(Intent.ACTION_SCREEN_ON);
+        filter.addAction(Intent.ACTION_SCREEN_OFF);
+
+        // Dreaming state
+        filter.addAction(Intent.ACTION_DREAMING_STARTED);
+        filter.addAction(Intent.ACTION_DREAMING_STOPPED);
+
+        // Debugging/instrumentation
+        filter.addAction(ActivityManagerService.ACTION_TRIGGER_IDLE);
+
+        // Wireless charging dock state
+        filter.addAction(Intent.ACTION_DOCK_IDLE);
+        filter.addAction(Intent.ACTION_DOCK_ACTIVE);
+
+        context.registerReceiver(this, filter);
+    }
+
+    @Override
+    public void dump(PrintWriter pw) {
+        pw.print("  mIdle: "); pw.println(mIdle);
+        pw.print("  mScreenOn: "); pw.println(mScreenOn);
+        pw.print("  mDockIdle: "); pw.println(mDockIdle);
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        final String action = intent.getAction();
+        if (action.equals(Intent.ACTION_SCREEN_ON)
+                || action.equals(Intent.ACTION_DREAMING_STOPPED)
+                || action.equals(Intent.ACTION_DOCK_ACTIVE)) {
+            if (action.equals(Intent.ACTION_DOCK_ACTIVE)) {
+                if (!mScreenOn) {
+                    // Ignore this intent during screen off
+                    return;
+                } else {
+                    mDockIdle = false;
+                }
+            } else {
+                mScreenOn = true;
+                mDockIdle = false;
+            }
+            if (DEBUG) {
+                Slog.v(TAG,"exiting idle : " + action);
+            }
+            //cancel the alarm
+            mAlarm.cancel(mIdleAlarmListener);
+            if (mIdle) {
+            // possible transition to not-idle
+                mIdle = false;
+                mIdleListener.reportNewIdleState(mIdle);
+            }
+        } else if (action.equals(Intent.ACTION_SCREEN_OFF)
+                || action.equals(Intent.ACTION_DREAMING_STARTED)
+                || action.equals(Intent.ACTION_DOCK_IDLE)) {
+            // when the screen goes off or dreaming starts or wireless charging dock in idle,
+            // we schedule the alarm that will tell us when we have decided the device is
+            // truly idle.
+            if (action.equals(Intent.ACTION_DOCK_IDLE)) {
+                if (!mScreenOn) {
+                    // Ignore this intent during screen off
+                    return;
+                } else {
+                    mDockIdle = true;
+                }
+            } else {
+                mScreenOn = false;
+                mDockIdle = false;
+            }
+            final long nowElapsed = sElapsedRealtimeClock.millis();
+            final long when = nowElapsed + mInactivityIdleThreshold;
+            if (DEBUG) {
+                Slog.v(TAG, "Scheduling idle : " + action + " now:" + nowElapsed + " when="
+                        + when);
+            }
+            mAlarm.setWindow(AlarmManager.ELAPSED_REALTIME_WAKEUP,
+                    when, mIdleWindowSlop, "JS idleness", mIdleAlarmListener, null);
+        } else if (action.equals(ActivityManagerService.ACTION_TRIGGER_IDLE)) {
+            handleIdleTrigger();
+        }
+    }
+
+    private void handleIdleTrigger() {
+        // idle time starts now. Do not set mIdle if screen is on.
+        if (!mIdle && (!mScreenOn || mDockIdle)) {
+            if (DEBUG) {
+                Slog.v(TAG, "Idle trigger fired @ " + sElapsedRealtimeClock.millis());
+            }
+            mIdle = true;
+            mIdleListener.reportNewIdleState(mIdle);
+        } else {
+            if (DEBUG) {
+                Slog.v(TAG, "TRIGGER_IDLE received but not changing state; idle="
+                        + mIdle + " screen=" + mScreenOn);
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/IdlenessListener.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/IdlenessListener.java
new file mode 100644
index 0000000..7ffd7cd
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/IdlenessListener.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2018 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.controllers.idle;
+
+/**
+ * Interface through which an IdlenessTracker informs the job scheduler of
+ * changes in the device's inactivity state.
+ */
+public interface IdlenessListener {
+    /**
+     * Tell the job scheduler that the device's idle state has changed.
+     *
+     * @param deviceIsIdle {@code true} to indicate that the device is now considered
+     * to be idle; {@code false} to indicate that the device is now being interacted with,
+     * so jobs with idle constraints should not be run.
+     */
+    void reportNewIdleState(boolean deviceIsIdle);
+}
diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/IdlenessTracker.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/IdlenessTracker.java
new file mode 100644
index 0000000..09f01c2
--- /dev/null
+++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/idle/IdlenessTracker.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2018 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.controllers.idle;
+
+import android.content.Context;
+
+import java.io.PrintWriter;
+
+public interface IdlenessTracker {
+    /**
+     * One-time initialization:  this method is called once, after construction of
+     * the IdlenessTracker instance.  This is when the tracker should actually begin
+     * monitoring whatever signals it consumes in deciding when the device is in a
+     * non-interacting state.  When the idle state changes thereafter, the given
+     * listener must be called to report the new state.
+     */
+    void startTracking(Context context, IdlenessListener listener);
+
+    /**
+     * Report whether the device is currently considered "idle" for purposes of
+     * running scheduled jobs with idleness constraints.
+     *
+     * @return {@code true} if the job scheduler should consider idleness
+     * constraints to be currently satisfied; {@code false} otherwise.
+     */
+    boolean isIdle();
+
+    /**
+     * Dump useful information about tracked idleness-related state in plaintext.
+     */
+    void dump(PrintWriter pw);
+}