Fix QuotaController job spam throttling.

QuotaController was inadvertently updating all Timers for a particular
user whenever any process state crossed the FOREGROUND_SERVICE
threshold, instead of only updating the Timer for the specific UID.

Also adding more data to QuotaController's dump to make future debugging
easier.

Bug: 129117282
Test: atest com.android.server.job.controllers.QuotaControllerTest
Test: atest CtsJobSchedulerTestCases
Change-Id: Ic8d9e6478e61cc62318ae5651f0526e41a71de8d
diff --git a/core/java/android/util/SparseSetArray.java b/core/java/android/util/SparseSetArray.java
index 680e85f..c1873d7 100644
--- a/core/java/android/util/SparseSetArray.java
+++ b/core/java/android/util/SparseSetArray.java
@@ -44,6 +44,13 @@
     }
 
     /**
+     * Removes all mappings from this SparseSetArray.
+     */
+    public void clear() {
+        mData.clear();
+    }
+
+    /**
      * @return whether a value exists at index n.
      */
     public boolean contains(int n, T value) {
diff --git a/core/proto/android/server/jobscheduler.proto b/core/proto/android/server/jobscheduler.proto
index 1e0b0d8..dc2e6d5 100644
--- a/core/proto/android/server/jobscheduler.proto
+++ b/core/proto/android/server/jobscheduler.proto
@@ -478,6 +478,54 @@
         }
         repeated TrackedJob tracked_jobs = 4;
 
+        message ExecutionStats {
+            option (.android.msg_privacy).dest = DEST_AUTOMATIC;
+
+            optional JobStatusDumpProto.Bucket standby_bucket = 1;
+
+            // The time after which this record should be considered invalid (out of date), in the
+            // elapsed realtime timebase.
+            optional int64 expiration_time_elapsed = 2;
+            optional int64 window_size_ms = 3;
+
+            /** The total amount of time the app ran in its respective bucket window size. */
+            optional int64 execution_time_in_window_ms = 4;
+            optional int32 bg_job_count_in_window = 5;
+
+            /**
+             * The total amount of time the app ran in the last
+             * {@link QuotaController#MAX_PERIOD_MS}.
+             */
+            optional int64 execution_time_in_max_period_ms = 6;
+            optional int32 bg_job_count_in_max_period = 7;
+
+            /**
+             * The time after which the sum of all the app's sessions plus
+             * ConstantsProto.QuotaController.in_quota_buffer_ms equals the quota. This is only
+             * valid if
+             * execution_time_in_window_ms >=
+             *   ConstantsProto.QuotaController.allowed_time_per_period_ms
+             * or
+             * execution_time_in_max_period_ms >=
+             *   ConstantsProto.QuotaController.max_execution_time_ms.
+             */
+            optional int64 quota_cutoff_time_elapsed = 8;
+
+            /**
+             * The time after which job_count_in_allowed_time should be considered invalid, in the
+             * elapsed realtime timebase.
+             */
+            optional int64 job_count_expiration_time_elapsed = 9;
+
+            /**
+             * The number of jobs that ran in at least the last
+             * ConstantsProto.QuotaController.allowed_time_per_period_ms.
+             * It may contain a few stale entries since cleanup won't happen exactly every
+             * ConstantsProto.QuotaController.allowed_time_per_period_ms.
+             */
+            optional int32 job_count_in_allowed_time = 10;
+        }
+
         message Package {
             option (.android.msg_privacy).dest = DEST_AUTOMATIC;
 
@@ -517,6 +565,8 @@
             optional Timer timer = 2;
 
             repeated TimingSession saved_sessions = 3;
+
+            repeated ExecutionStats execution_stats = 4;
         }
         repeated PackageStats package_stats = 5;
     }
diff --git a/services/core/java/com/android/server/job/controllers/QuotaController.java b/services/core/java/com/android/server/job/controllers/QuotaController.java
index 1820acf..ccd1db4 100644
--- a/services/core/java/com/android/server/job/controllers/QuotaController.java
+++ b/services/core/java/com/android/server/job/controllers/QuotaController.java
@@ -29,6 +29,7 @@
 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;
@@ -49,6 +50,7 @@
 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;
@@ -277,9 +279,9 @@
                     .append(", ")
                     .append("bgJobCountInMaxPeriod=").append(bgJobCountInMaxPeriod).append(", ")
                     .append("quotaCutoffTime=").append(quotaCutoffTimeElapsed).append(", ")
-                    .append("jobCountExpirationTime").append(jobCountExpirationTimeElapsed)
+                    .append("jobCountExpirationTime=").append(jobCountExpirationTimeElapsed)
                     .append(", ")
-                    .append("jobCountInAllowedTime").append(jobCountInAllowedTime)
+                    .append("jobCountInAllowedTime=").append(jobCountInAllowedTime)
                     .toString();
         }
 
@@ -338,6 +340,9 @@
     /** 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
@@ -421,6 +426,22 @@
         }
     };
 
+    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.
@@ -469,6 +490,9 @@
         mActivityManagerInternal = LocalServices.getService(ActivityManagerInternal.class);
         mAlarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
 
+        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);
@@ -502,10 +526,15 @@
 
     @Override
     public void prepareForExecutionLocked(JobStatus jobStatus) {
-        if (DEBUG) Slog.d(TAG, "Prepping for " + jobStatus.toShortString());
+        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;
@@ -518,7 +547,7 @@
             timer = new Timer(uid, userId, packageName);
             mPkgTimers.add(userId, packageName, timer);
         }
-        timer.startTrackingJob(jobStatus);
+        timer.startTrackingJobLocked(jobStatus);
     }
 
     @Override
@@ -645,7 +674,7 @@
         if (timer != null) {
             if (timer.isActive()) {
                 Slog.wtf(TAG, "onAppRemovedLocked called before Timer turned off.");
-                timer.dropEverything();
+                timer.dropEverythingLocked();
             }
             mPkgTimers.delete(userId, packageName);
         }
@@ -657,6 +686,7 @@
         }
         mExecutionStatsCache.delete(userId, packageName);
         mForegroundUids.delete(uid);
+        mUidToPackageCache.remove(uid);
     }
 
     @Override
@@ -666,6 +696,7 @@
         mTimingSessions.delete(userId);
         mInQuotaAlarmListeners.delete(userId);
         mExecutionStatsCache.delete(userId);
+        mUidToPackageCache.clear();
     }
 
     private boolean isUidInForeground(int uid) {
@@ -678,7 +709,7 @@
     }
 
     /** @return true if the job was started while the app was in the TOP state. */
-    private boolean isTopStartedJob(@NonNull final JobStatus jobStatus) {
+    private boolean isTopStartedJobLocked(@NonNull final JobStatus jobStatus) {
         return mTopStartedJobs.contains(jobStatus);
     }
 
@@ -695,14 +726,14 @@
         return jobStatus.getStandbyBucket();
     }
 
-    private boolean isWithinQuotaLocked(@NonNull final JobStatus jobStatus) {
+    @VisibleForTesting
+    boolean isWithinQuotaLocked(@NonNull final JobStatus jobStatus) {
         final int standbyBucket = getEffectiveStandbyBucket(jobStatus);
-        Timer timer = mPkgTimers.get(jobStatus.getSourceUserId(), jobStatus.getSourcePackageName());
         // 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 isTopStartedJob(jobStatus)
+        return isTopStartedJobLocked(jobStatus)
                 || isUidInForeground(jobStatus.getSourceUid())
                 || isWithinQuotaLocked(
                 jobStatus.getSourceUserId(), jobStatus.getSourcePackageName(), standbyBucket);
@@ -1081,7 +1112,9 @@
         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");
+            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
@@ -1095,15 +1128,19 @@
         mNextCleanupTimeElapsed = nextCleanupElapsed;
         mAlarmManager.set(AlarmManager.ELAPSED_REALTIME, nextCleanupElapsed, ALARM_TAG_CLEANUP,
                 mSessionCleanupAlarmListener, mHandler);
-        if (DEBUG) Slog.d(TAG, "Scheduled next cleanup for " + mNextCleanupTimeElapsed);
+        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);
+        if (DEBUG) {
+            Slog.d(TAG, "handleNewChargingStateLocked: " + isCharging);
+        }
         // Deal with Timers first.
-        mPkgTimers.forEach((t) -> t.onStateChanged(nowElapsed, isCharging));
+        mPkgTimers.forEach((t) -> t.onStateChangedLocked(nowElapsed, isCharging));
         // Now update jobs.
         maybeUpdateAllConstraintsLocked();
     }
@@ -1140,7 +1177,7 @@
         boolean changed = false;
         for (int i = jobs.size() - 1; i >= 0; --i) {
             final JobStatus js = jobs.valueAt(i);
-            if (isTopStartedJob(js)) {
+            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);
@@ -1282,7 +1319,9 @@
         if (!alarmListener.isWaiting()
                 || inQuotaTimeElapsed < alarmListener.getTriggerTimeElapsed() - 3 * MINUTE_IN_MILLIS
                 || alarmListener.getTriggerTimeElapsed() < inQuotaTimeElapsed) {
-            if (DEBUG) Slog.d(TAG, "Scheduling start alarm for " + pkgString);
+            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,
@@ -1430,8 +1469,8 @@
             mUid = uid;
         }
 
-        void startTrackingJob(@NonNull JobStatus jobStatus) {
-            if (isTopStartedJob(jobStatus)) {
+        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) {
@@ -1440,27 +1479,28 @@
                 }
                 return;
             }
-            if (DEBUG) Slog.v(TAG, "Starting to track " + jobStatus.toShortString());
-            synchronized (mLock) {
-                // 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();
-                    }
+            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());
+            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
@@ -1482,7 +1522,7 @@
          * 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 dropEverything() {
+        void dropEverythingLocked() {
             mRunningBgJobs.clear();
             cancelCutoff();
         }
@@ -1531,25 +1571,23 @@
             return !mChargeTracker.isCharging() && !mForegroundUids.get(mUid);
         }
 
-        void onStateChanged(long nowElapsed, boolean isQuotaFree) {
-            synchronized (mLock) {
-                if (isQuotaFree) {
-                    emitSessionLocked(nowElapsed);
-                } else if (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 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();
                 }
             }
         }
@@ -1604,7 +1642,6 @@
                     pw.println(js.toShortString());
                 }
             }
-
             pw.decreaseIndent();
         }
 
@@ -1667,7 +1704,9 @@
         @Override
         public void onParoleStateChanged(final boolean isParoleOn) {
             mInParole = isParoleOn;
-            if (DEBUG) Slog.i(TAG, "Global parole state now " + (isParoleOn ? "ON" : "OFF"));
+            if (DEBUG) {
+                Slog.i(TAG, "Global parole state now " + (isParoleOn ? "ON" : "OFF"));
+            }
             // Update job bookkeeping out of band.
             BackgroundThread.getHandler().post(() -> {
                 synchronized (mLock) {
@@ -1712,7 +1751,9 @@
                 switch (msg.what) {
                     case MSG_REACHED_QUOTA: {
                         Package pkg = (Package) msg.obj;
-                        if (DEBUG) Slog.d(TAG, "Checking if " + pkg + " has reached its quota.");
+                        if (DEBUG) {
+                            Slog.d(TAG, "Checking if " + pkg + " has reached its quota.");
+                        }
 
                         long timeRemainingMs = getRemainingExecutionTimeLocked(pkg.userId,
                                 pkg.packageName);
@@ -1737,7 +1778,9 @@
                         break;
                     }
                     case MSG_CLEAN_UP_SESSIONS:
-                        if (DEBUG) Slog.d(TAG, "Cleaning up timing sessions.");
+                        if (DEBUG) {
+                            Slog.d(TAG, "Cleaning up timing sessions.");
+                        }
                         deleteObsoleteSessionsLocked();
                         maybeScheduleCleanupAlarmLocked();
 
@@ -1745,7 +1788,9 @@
                     case MSG_CHECK_PACKAGE: {
                         String packageName = (String) msg.obj;
                         int userId = msg.arg1;
-                        if (DEBUG) Slog.d(TAG, "Checking pkg " + string(userId, packageName));
+                        if (DEBUG) {
+                            Slog.d(TAG, "Checking pkg " + string(userId, packageName));
+                        }
                         if (maybeUpdateConstraintForPkgLocked(userId, packageName)) {
                             mStateChangedListener.onControllerStateChanged();
                         }
@@ -1767,13 +1812,28 @@
                                 isQuotaFree = false;
                             }
                             // Update Timers first.
-                            final int userIndex = mPkgTimers.indexOfKey(userId);
-                            if (userIndex != -1) {
-                                final int numPkgs = mPkgTimers.numPackagesForUser(userId);
-                                for (int p = 0; p < numPkgs; ++p) {
-                                    Timer t = mPkgTimers.valueAt(userIndex, p);
-                                    if (t != null) {
-                                        t.onStateChanged(nowElapsed, isQuotaFree);
+                            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);
+                                        }
                                     }
                                 }
                             }
@@ -1883,6 +1943,17 @@
         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);
@@ -1936,6 +2007,29 @@
                 }
             }
         }
+
+        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();
     }
 
     @Override
@@ -1995,6 +2089,49 @@
                     }
                 }
 
+                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.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.QUOTA_CUTOFF_TIME_ELAPSED,
+                                es.quotaCutoffTimeElapsed);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.JOB_COUNT_EXPIRATION_TIME_ELAPSED,
+                                es.jobCountExpirationTimeElapsed);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.JOB_COUNT_IN_ALLOWED_TIME,
+                                es.jobCountInAllowedTime);
+                        proto.end(esToken);
+                    }
+                }
+
                 proto.end(psToken);
             }
         }
diff --git a/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java b/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java
index 08f6a37..f492d13 100644
--- a/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java
+++ b/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java
@@ -58,6 +58,7 @@
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
+import android.content.pm.IPackageManager;
 import android.content.pm.PackageManagerInternal;
 import android.os.BatteryManager;
 import android.os.BatteryManagerInternal;
@@ -224,50 +225,55 @@
     }
 
     private void setProcessState(int procState) {
+        setProcessState(procState, mSourceUid);
+    }
+
+    private void setProcessState(int procState, int uid) {
         try {
-            doReturn(procState).when(mActivityMangerInternal).getUidProcessState(mSourceUid);
+            doReturn(procState).when(mActivityMangerInternal).getUidProcessState(uid);
             SparseBooleanArray foregroundUids = mQuotaController.getForegroundUids();
             spyOn(foregroundUids);
-            mUidObserver.onUidStateChanged(mSourceUid, procState, 0);
+            mUidObserver.onUidStateChanged(uid, procState, 0);
             if (procState <= ActivityManager.PROCESS_STATE_FOREGROUND_SERVICE) {
-                verify(foregroundUids, timeout(SECOND_IN_MILLIS).times(1))
-                        .put(eq(mSourceUid), eq(true));
-                assertTrue(foregroundUids.get(mSourceUid));
+                verify(foregroundUids, timeout(2 * SECOND_IN_MILLIS).times(1))
+                        .put(eq(uid), eq(true));
+                assertTrue(foregroundUids.get(uid));
             } else {
-                verify(foregroundUids, timeout(SECOND_IN_MILLIS).times(1)).delete(eq(mSourceUid));
-                assertFalse(foregroundUids.get(mSourceUid));
+                verify(foregroundUids, timeout(2 * SECOND_IN_MILLIS).times(1)).delete(eq(uid));
+                assertFalse(foregroundUids.get(uid));
             }
         } catch (RemoteException e) {
             fail("registerUidObserver threw exception: " + e.getMessage());
         }
     }
 
-    private void setStandbyBucket(int bucketIndex) {
-        int bucket;
+    private int bucketIndexToUsageStatsBucket(int bucketIndex) {
         switch (bucketIndex) {
             case ACTIVE_INDEX:
-                bucket = UsageStatsManager.STANDBY_BUCKET_ACTIVE;
-                break;
+                return UsageStatsManager.STANDBY_BUCKET_ACTIVE;
             case WORKING_INDEX:
-                bucket = UsageStatsManager.STANDBY_BUCKET_WORKING_SET;
-                break;
+                return UsageStatsManager.STANDBY_BUCKET_WORKING_SET;
             case FREQUENT_INDEX:
-                bucket = UsageStatsManager.STANDBY_BUCKET_FREQUENT;
-                break;
+                return UsageStatsManager.STANDBY_BUCKET_FREQUENT;
             case RARE_INDEX:
-                bucket = UsageStatsManager.STANDBY_BUCKET_RARE;
-                break;
+                return UsageStatsManager.STANDBY_BUCKET_RARE;
             default:
-                bucket = UsageStatsManager.STANDBY_BUCKET_NEVER;
+                return UsageStatsManager.STANDBY_BUCKET_NEVER;
         }
+    }
+
+    private void setStandbyBucket(int bucketIndex) {
         when(mUsageStatsManager.getAppStandbyBucket(eq(SOURCE_PACKAGE), eq(SOURCE_USER_ID),
-                anyLong())).thenReturn(bucket);
+                anyLong())).thenReturn(bucketIndexToUsageStatsBucket(bucketIndex));
     }
 
     private void setStandbyBucket(int bucketIndex, JobStatus... jobs) {
         setStandbyBucket(bucketIndex);
         for (JobStatus job : jobs) {
             job.setStandbyBucket(bucketIndex);
+            when(mUsageStatsManager.getAppStandbyBucket(
+                    eq(job.getSourcePackageName()), eq(job.getSourceUserId()), anyLong()))
+                    .thenReturn(bucketIndexToUsageStatsBucket(bucketIndex));
         }
     }
 
@@ -283,8 +289,13 @@
                 new ComponentName(mContext, "TestQuotaJobService"))
                 .setMinimumLatency(Math.abs(jobId) + 1)
                 .build();
+        return createJobStatus(testTag, SOURCE_PACKAGE, CALLING_UID, jobInfo);
+    }
+
+    private JobStatus createJobStatus(String testTag, String packageName, int callingUid,
+            JobInfo jobInfo) {
         JobStatus js = JobStatus.createFromJobInfo(
-                jobInfo, CALLING_UID, SOURCE_PACKAGE, SOURCE_USER_ID, testTag);
+                jobInfo, callingUid, packageName, SOURCE_USER_ID, testTag);
         // Make sure tests aren't passing just because the default bucket is likely ACTIVE.
         js.setStandbyBucket(FREQUENT_INDEX);
         return js;
@@ -935,6 +946,115 @@
     }
 
     @Test
+    public void testIsWithinQuotaLocked_UnderDuration_UnderJobCount_MultiStateChange_BelowFGS() {
+        setDischarging();
+
+        JobStatus jobStatus = createJobStatus(
+                "testIsWithinQuotaLocked_UnderDuration_UnderJobCount_MultiStateChange_BelowFGS", 1);
+        setStandbyBucket(ACTIVE_INDEX, jobStatus);
+        setProcessState(ActivityManager.PROCESS_STATE_BACKUP);
+
+        mQuotaController.maybeStartTrackingJobLocked(jobStatus, null);
+        mQuotaController.prepareForExecutionLocked(jobStatus);
+        for (int i = 0; i < 20; ++i) {
+            advanceElapsedClock(SECOND_IN_MILLIS);
+            setProcessState(ActivityManager.PROCESS_STATE_SERVICE);
+            setProcessState(ActivityManager.PROCESS_STATE_IMPORTANT_BACKGROUND);
+        }
+        mQuotaController.maybeStopTrackingJobLocked(jobStatus, null, false);
+
+        advanceElapsedClock(15 * SECOND_IN_MILLIS);
+
+        mQuotaController.maybeStartTrackingJobLocked(jobStatus, null);
+        mQuotaController.prepareForExecutionLocked(jobStatus);
+        for (int i = 0; i < 20; ++i) {
+            advanceElapsedClock(SECOND_IN_MILLIS);
+            setProcessState(ActivityManager.PROCESS_STATE_SERVICE);
+            setProcessState(ActivityManager.PROCESS_STATE_RECEIVER);
+        }
+        mQuotaController.maybeStopTrackingJobLocked(jobStatus, null, false);
+
+        advanceElapsedClock(10 * MINUTE_IN_MILLIS + 30 * SECOND_IN_MILLIS);
+
+        assertEquals(2, mQuotaController.getExecutionStatsLocked(
+                SOURCE_USER_ID, SOURCE_PACKAGE, ACTIVE_INDEX).jobCountInAllowedTime);
+        assertTrue(mQuotaController.isWithinQuotaLocked(jobStatus));
+    }
+
+    @Test
+    public void testIsWithinQuotaLocked_UnderDuration_UnderJobCount_MultiStateChange_SeparateApps()
+            throws Exception {
+        setDischarging();
+
+        final String unaffectedPkgName = "com.android.unaffected";
+        final int unaffectedUid = 10987;
+        JobInfo unaffectedJobInfo = new JobInfo.Builder(1,
+                new ComponentName(unaffectedPkgName, "foo"))
+                .build();
+        JobStatus unaffected = createJobStatus(
+                "testIsWithinQuotaLocked_UnderDuration_UnderJobCount_MultiStateChange_SeparateApps",
+                unaffectedPkgName, unaffectedUid, unaffectedJobInfo);
+        setStandbyBucket(FREQUENT_INDEX, unaffected);
+        setProcessState(ActivityManager.PROCESS_STATE_SERVICE, unaffectedUid);
+
+        final String fgChangerPkgName = "com.android.foreground.changer";
+        final int fgChangerUid = 10234;
+        JobInfo fgChangerJobInfo = new JobInfo.Builder(2,
+                new ComponentName(fgChangerPkgName, "foo"))
+                .build();
+        JobStatus fgStateChanger = createJobStatus(
+                "testIsWithinQuotaLocked_UnderDuration_UnderJobCount_MultiStateChange_SeparateApps",
+                fgChangerPkgName, fgChangerUid, fgChangerJobInfo);
+        setStandbyBucket(ACTIVE_INDEX, fgStateChanger);
+        setProcessState(ActivityManager.PROCESS_STATE_BACKUP, fgChangerUid);
+
+        IPackageManager packageManager = AppGlobals.getPackageManager();
+        spyOn(packageManager);
+        doReturn(new String[]{unaffectedPkgName})
+                .when(packageManager).getPackagesForUid(unaffectedUid);
+        doReturn(new String[]{fgChangerPkgName})
+                .when(packageManager).getPackagesForUid(fgChangerUid);
+
+        mQuotaController.maybeStartTrackingJobLocked(unaffected, null);
+        mQuotaController.prepareForExecutionLocked(unaffected);
+
+        mQuotaController.maybeStartTrackingJobLocked(fgStateChanger, null);
+        mQuotaController.prepareForExecutionLocked(fgStateChanger);
+        for (int i = 0; i < 20; ++i) {
+            advanceElapsedClock(SECOND_IN_MILLIS);
+            setProcessState(ActivityManager.PROCESS_STATE_TOP, fgChangerUid);
+            setProcessState(ActivityManager.PROCESS_STATE_TOP_SLEEPING, fgChangerUid);
+        }
+        mQuotaController.maybeStopTrackingJobLocked(fgStateChanger, null, false);
+
+        advanceElapsedClock(15 * SECOND_IN_MILLIS);
+
+        mQuotaController.maybeStartTrackingJobLocked(fgStateChanger, null);
+        mQuotaController.prepareForExecutionLocked(fgStateChanger);
+        for (int i = 0; i < 20; ++i) {
+            advanceElapsedClock(SECOND_IN_MILLIS);
+            setProcessState(ActivityManager.PROCESS_STATE_TOP, fgChangerUid);
+            setProcessState(ActivityManager.PROCESS_STATE_TOP_SLEEPING, fgChangerUid);
+        }
+        mQuotaController.maybeStopTrackingJobLocked(fgStateChanger, null, false);
+
+        mQuotaController.maybeStopTrackingJobLocked(unaffected, null, false);
+
+        assertTrue(mQuotaController.isWithinQuotaLocked(unaffected));
+        assertFalse(mQuotaController.isWithinQuotaLocked(fgStateChanger));
+        assertEquals(1,
+                mQuotaController.getTimingSessions(SOURCE_USER_ID, unaffectedPkgName).size());
+        assertEquals(42,
+                mQuotaController.getTimingSessions(SOURCE_USER_ID, fgChangerPkgName).size());
+        for (int i = ACTIVE_INDEX; i < RARE_INDEX; ++i) {
+            assertEquals(42, mQuotaController.getExecutionStatsLocked(
+                    SOURCE_USER_ID, fgChangerPkgName, i).jobCountInAllowedTime);
+            assertEquals(1, mQuotaController.getExecutionStatsLocked(
+                    SOURCE_USER_ID, unaffectedPkgName, i).jobCountInAllowedTime);
+        }
+    }
+
+    @Test
     public void testMaybeScheduleCleanupAlarmLocked() {
         // No sessions saved yet.
         mQuotaController.maybeScheduleCleanupAlarmLocked();