Add throttling by job run session.

A session is considered a period of time when jobs for an app ran.
Overlapping jobs are counted as part of the same session. This adds a
way to limit the number of job sessions an app can run. This includes a
mechanism to coalesce sessions -- if a second session started soon after
one just ended, they will only be counted as one session.

Bug: 132227621
Test: atest com.android.server.job.controllers.QuotaControllerTest
Test: atest CtsJobSchedulerTestCases
Change-Id: Id2ac4037731f57547d00985e8d549b9e990a5f3e
diff --git a/core/proto/android/server/jobscheduler.proto b/core/proto/android/server/jobscheduler.proto
index 0df2c83..3f8ddff 100644
--- a/core/proto/android/server/jobscheduler.proto
+++ b/core/proto/android/server/jobscheduler.proto
@@ -276,6 +276,24 @@
         // The maximum number of jobs that should be allowed to run in the past
         // {@link QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS}.
         optional int32 max_job_count_per_allowed_time = 12;
+        // The maximum number of timing sessions an app can run within this particular standby
+        // bucket's window size.
+        optional int32 max_session_count_active = 13;
+        // The maximum number of timing sessions an app can run within this particular standby
+        // bucket's window size.
+        optional int32 max_session_count_working = 14;
+        // The maximum number of timing sessions an app can run within this particular standby
+        // bucket's window size.
+        optional int32 max_session_count_frequent = 15;
+        // The maximum number of timing sessions an app can run within this particular standby
+        // bucket's window size.
+        optional int32 max_session_count_rare = 16;
+        // The maximum number of timing sessions that should be allowed to run in the past
+        // {@link QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS}.
+        optional int32 max_session_count_per_allowed_time = 17;
+        // Treat two distinct {@link TimingSession}s as the same if they start and end within this
+        // amount of time of each other.
+        optional int64 timing_session_coalescing_duration_ms = 18;
     }
     optional QuotaController quota_controller = 24;
 
@@ -511,6 +529,12 @@
             optional int32 bg_job_count_in_max_period = 7;
 
             /**
+             * 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.
+             */
+            optional int32 session_count_in_window = 11;
+
+            /**
              * 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
@@ -535,6 +559,21 @@
              * ConstantsProto.QuotaController.allowed_time_per_period_ms.
              */
             optional int32 job_count_in_allowed_time = 10;
+
+            /**
+             * The time after which {@link #timingSessionCountInAllowedTime} should be considered
+             * invalid, in the elapsed realtime timebase.
+             */
+            optional int64 session_count_expiration_time_elapsed = 12;
+
+            /**
+             * The number of {@link TimingSession}s that ran in at least the last
+             * {@link #mAllowedTimePerPeriodMs}. It may contain a few stale entries since cleanup won't
+             * happen exactly every {@link #mAllowedTimePerPeriodMs}. This should only be considered
+             * valid before elapsed realtime has reached
+             * {@link #timingSessionCountExpirationTimeElapsed}.
+             */
+            optional int32 session_count_in_allowed_time = 13;
         }
 
         message Package {
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 2a9d3f3..f560d69 100644
--- a/services/core/java/com/android/server/job/controllers/QuotaController.java
+++ b/services/core/java/com/android/server/job/controllers/QuotaController.java
@@ -254,6 +254,12 @@
         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 sum of all the app's sessions plus {@link #mQuotaBufferMs}
          * equals the quota. This is only valid if
          * executionTimeInWindowMs >= {@link #mAllowedTimePerPeriodMs} or
@@ -274,21 +280,34 @@
          */
         public int jobCountInAllowedTime;
 
+        /**
+         * The time after which {@link #sessionCountInAllowedTime} should be considered
+         * invalid, in the elapsed realtime timebase.
+         */
+        public long sessionCountExpirationTimeElapsed;
+
+        /**
+         * The number of {@link TimingSession}s that ran in at least the last
+         * {@link #mAllowedTimePerPeriodMs}. It may contain a few stale entries since cleanup won't
+         * happen exactly every {@link #mAllowedTimePerPeriodMs}. This should only be considered
+         * valid before elapsed realtime has reached {@link #sessionCountExpirationTimeElapsed}.
+         */
+        public int sessionCountInAllowedTime;
+
         @Override
         public String toString() {
-            return new StringBuilder()
-                    .append("expirationTime=").append(expirationTimeElapsed).append(", ")
-                    .append("windowSize=").append(windowSizeMs).append(", ")
-                    .append("executionTimeInWindow=").append(executionTimeInWindowMs).append(", ")
-                    .append("bgJobCountInWindow=").append(bgJobCountInWindow).append(", ")
-                    .append("executionTimeInMaxPeriod=").append(executionTimeInMaxPeriodMs)
-                    .append(", ")
-                    .append("bgJobCountInMaxPeriod=").append(bgJobCountInMaxPeriod).append(", ")
-                    .append("quotaCutoffTime=").append(quotaCutoffTimeElapsed).append(", ")
-                    .append("jobCountExpirationTime=").append(jobCountExpirationTimeElapsed)
-                    .append(", ")
-                    .append("jobCountInAllowedTime=").append(jobCountInAllowedTime)
-                    .toString();
+            return "expirationTime=" + expirationTimeElapsed + ", "
+                    + "windowSize=" + windowSizeMs + ", "
+                    + "executionTimeInWindow=" + executionTimeInWindowMs + ", "
+                    + "bgJobCountInWindow=" + bgJobCountInWindow + ", "
+                    + "executionTimeInMaxPeriod=" + executionTimeInMaxPeriodMs + ", "
+                    + "bgJobCountInMaxPeriod=" + bgJobCountInMaxPeriod + ", "
+                    + "sessionCountInWindow=" + sessionCountInWindow + ", "
+                    + "quotaCutoffTime=" + quotaCutoffTimeElapsed + ", "
+                    + "jobCountExpirationTime=" + jobCountExpirationTimeElapsed + ", "
+                    + "jobCountInAllowedTime=" + jobCountInAllowedTime + ", "
+                    + "sessionCountExpirationTime=" + sessionCountExpirationTimeElapsed + ", "
+                    + "sessionCountInAllowedTime=" + sessionCountInAllowedTime;
         }
 
         @Override
@@ -300,10 +319,15 @@
                         && this.executionTimeInWindowMs == other.executionTimeInWindowMs
                         && this.bgJobCountInWindow == other.bgJobCountInWindow
                         && this.executionTimeInMaxPeriodMs == other.executionTimeInMaxPeriodMs
+                        && this.sessionCountInWindow == other.sessionCountInWindow
                         && this.bgJobCountInMaxPeriod == other.bgJobCountInMaxPeriod
                         && this.quotaCutoffTimeElapsed == other.quotaCutoffTimeElapsed
                         && this.jobCountExpirationTimeElapsed == other.jobCountExpirationTimeElapsed
-                        && this.jobCountInAllowedTime == other.jobCountInAllowedTime;
+                        && this.jobCountInAllowedTime == other.jobCountInAllowedTime
+                        && this.sessionCountExpirationTimeElapsed
+                        == other.sessionCountExpirationTimeElapsed
+                        && this.sessionCountInAllowedTime
+                        == other.sessionCountInAllowedTime;
             } else {
                 return false;
             }
@@ -318,9 +342,12 @@
             result = 31 * result + bgJobCountInWindow;
             result = 31 * result + hashLong(executionTimeInMaxPeriodMs);
             result = 31 * result + bgJobCountInMaxPeriod;
+            result = 31 * result + sessionCountInWindow;
             result = 31 * result + hashLong(quotaCutoffTimeElapsed);
             result = 31 * result + hashLong(jobCountExpirationTimeElapsed);
             result = 31 * result + jobCountInAllowedTime;
+            result = 31 * result + hashLong(sessionCountExpirationTimeElapsed);
+            result = 31 * result + sessionCountInAllowedTime;
             return result;
         }
     }
@@ -401,6 +428,12 @@
     /** The maximum number of jobs that can run within the past {@link #mAllowedTimePerPeriodMs}. */
     private int mMaxJobCountPerAllowedTime = 20;
 
+    /**
+     * The maximum number of {@link TimingSession}s that can run within the past {@link
+     * #mAllowedTimePerPeriodMs}.
+     */
+    private int mMaxSessionCountPerAllowedTime = 20;
+
     private long mNextCleanupTimeElapsed = 0;
     private final AlarmManager.OnAlarmListener mSessionCleanupAlarmListener =
             new AlarmManager.OnAlarmListener() {
@@ -480,6 +513,29 @@
     /** The minimum number of jobs that any bucket will be allowed to run. */
     private static final int MIN_BUCKET_JOB_COUNT = 100;
 
+    /**
+     * 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
+    };
+
+    /** The minimum number of {@link TimingSession}s that any bucket will be allowed to run. */
+    private static final int MIN_BUCKET_SESSION_COUNT = 3;
+
+    /**
+     * 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 = 0;
+
     /** 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. */
@@ -695,14 +751,10 @@
             return true;
         }
 
-        return getRemainingExecutionTimeLocked(userId, packageName, standbyBucket) > 0
-                && isUnderJobCountQuotaLocked(userId, packageName, standbyBucket);
-    }
-
-    private boolean isUnderJobCountQuotaLocked(final int userId, @NonNull final String packageName,
-            final int standbyBucket) {
-        ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket, false);
-        return isUnderJobCountQuotaLocked(stats, standbyBucket);
+        ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket);
+        return getRemainingExecutionTimeLocked(stats) > 0
+                && isUnderJobCountQuotaLocked(stats, standbyBucket)
+                && isUnderSessionCountQuotaLocked(stats, standbyBucket);
     }
 
     private boolean isUnderJobCountQuotaLocked(@NonNull ExecutionStats stats,
@@ -715,6 +767,17 @@
                 && (stats.bgJobCountInWindow < mMaxBucketJobCounts[standbyBucket]);
     }
 
+    private boolean isUnderSessionCountQuotaLocked(@NonNull ExecutionStats stats,
+            final int standbyBucket) {
+        final long now = sElapsedRealtimeClock.millis();
+        final boolean isUnderAllowedTimeQuota =
+                (stats.sessionCountExpirationTimeElapsed <= now
+                        || stats.sessionCountInAllowedTime
+                        < mMaxSessionCountPerAllowedTime);
+        return isUnderAllowedTimeQuota
+                && stats.sessionCountInWindow < mMaxBucketSessionCounts[standbyBucket];
+    }
+
     @VisibleForTesting
     long getRemainingExecutionTimeLocked(@NonNull final JobStatus jobStatus) {
         return getRemainingExecutionTimeLocked(jobStatus.getSourceUserId(),
@@ -739,7 +802,11 @@
         if (standbyBucket == NEVER_INDEX) {
             return 0;
         }
-        final ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket);
+        return getRemainingExecutionTimeLocked(
+                getExecutionStatsLocked(userId, packageName, standbyBucket));
+    }
+
+    private long getRemainingExecutionTimeLocked(@NonNull ExecutionStats stats) {
         return Math.min(mAllowedTimePerPeriodMs - stats.executionTimeInWindowMs,
                 mMaxExecutionTimeMs - stats.executionTimeInMaxPeriodMs);
     }
@@ -877,6 +944,7 @@
         stats.bgJobCountInWindow = 0;
         stats.executionTimeInMaxPeriodMs = 0;
         stats.bgJobCountInMaxPeriod = 0;
+        stats.sessionCountInWindow = 0;
         stats.quotaCutoffTimeElapsed = 0;
 
         Timer timer = mPkgTimers.get(userId, packageName);
@@ -906,12 +974,14 @@
 
         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.
-        for (int i = sessions.size() - 1; i >= 0; --i) {
+        final int loopStart = sessions.size() - 1;
+        for (int i = loopStart; i >= 0; --i) {
             TimingSession session = sessions.get(i);
 
             // Window management.
@@ -924,6 +994,12 @@
                             session.startTimeElapsed + stats.executionTimeInWindowMs
                                     - mAllowedTimeIntoQuotaMs);
                 }
+                if (i == loopStart
+                        || (sessions.get(i + 1).startTimeElapsed - session.endTimeElapsed)
+                                > mTimingSessionCoalescingDurationMs) {
+                    // Coalesce sessions if they are very close to each other in time
+                    sessionCountInWindow++;
+                }
             } else if (startWindowElapsed < session.endTimeElapsed) {
                 // The session started before the window but ended within the window. Only include
                 // the portion that was within the window.
@@ -935,6 +1011,11 @@
                             startWindowElapsed + stats.executionTimeInWindowMs
                                     - mAllowedTimeIntoQuotaMs);
                 }
+                if (i == loopStart
+                        || (sessions.get(i + 1).startTimeElapsed - session.endTimeElapsed)
+                                > mTimingSessionCoalescingDurationMs) {
+                    sessionCountInWindow++;
+                }
             }
 
             // Max period check.
@@ -965,9 +1046,27 @@
             }
         }
         stats.expirationTimeElapsed = nowElapsed + emptyTimeMs;
+        stats.sessionCountInWindow = sessionCountInWindow;
     }
 
-    private void invalidateAllExecutionStatsLocked(final int userId,
+    /** 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) {
@@ -1003,6 +1102,27 @@
         }
     }
 
+    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.sessionCountExpirationTimeElapsed <= now) {
+                stats.sessionCountExpirationTimeElapsed = now + mAllowedTimePerPeriodMs;
+                stats.sessionCountInAllowedTime = 0;
+            }
+            stats.sessionCountInAllowedTime++;
+        }
+    }
+
     @VisibleForTesting
     void saveTimingSession(final int userId, @NonNull final String packageName,
             @NonNull final TimingSession session) {
@@ -1216,11 +1336,14 @@
         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) {
+                && isUnderJobCountQuota
+                && isUnderTimingSessionCountQuota) {
             // Already in quota. Why was this method called?
             if (DEBUG) {
                 Slog.e(TAG, "maybeScheduleStartAlarmLocked called for " + pkgString
@@ -1253,6 +1376,10 @@
             inQuotaTimeElapsed = Math.max(inQuotaTimeElapsed,
                     stats.jobCountExpirationTimeElapsed + mAllowedTimePerPeriodMs);
         }
+        if (!isUnderTimingSessionCountQuota) {
+            inQuotaTimeElapsed = Math.max(inQuotaTimeElapsed,
+                    stats.sessionCountExpirationTimeElapsed + mAllowedTimePerPeriodMs);
+        }
         // 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
@@ -1483,6 +1610,7 @@
             // of jobs.
             // However, cancel the currently scheduled cutoff since it's not currently useful.
             cancelCutoff();
+            incrementTimingSessionCount(mPkg.userId, mPkg.packageName);
         }
 
         /**
@@ -1842,6 +1970,14 @@
         private static final String KEY_MAX_JOB_COUNT_RARE = "max_job_count_rare";
         private static final String KEY_MAX_JOB_COUNT_PER_ALLOWED_TIME =
                 "max_count_per_allowed_time";
+        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_ALLOWED_TIME =
+                "max_session_count_per_allowed_time";
+        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
@@ -1866,6 +2002,16 @@
         private static final int DEFAULT_MAX_JOB_COUNT_RARE =
                 2400; // 100/hr
         private static final int DEFAULT_MAX_JOB_COUNT_PER_ALLOWED_TIME = 20;
+        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_ALLOWED_TIME = 20;
+        private static final long DEFAULT_TIMING_SESSION_COALESCING_DURATION_MS = 0;
 
         /** 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;
@@ -1939,6 +2085,43 @@
          */
         public int MAX_JOB_COUNT_PER_ALLOWED_TIME = DEFAULT_MAX_JOB_COUNT_PER_ALLOWED_TIME;
 
+        /**
+         * 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_ALLOWED_TIME = DEFAULT_MAX_SESSION_COUNT_PER_ALLOWED_TIME;
+
+        /**
+         * 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;
+
         QcConstants(Handler handler) {
             super(handler);
         }
@@ -1986,6 +2169,20 @@
                     KEY_MAX_JOB_COUNT_RARE, DEFAULT_MAX_JOB_COUNT_RARE);
             MAX_JOB_COUNT_PER_ALLOWED_TIME = mParser.getInt(
                     KEY_MAX_JOB_COUNT_PER_ALLOWED_TIME, DEFAULT_MAX_JOB_COUNT_PER_ALLOWED_TIME);
+            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_ALLOWED_TIME = mParser.getInt(
+                    KEY_MAX_SESSION_COUNT_PER_ALLOWED_TIME,
+                    DEFAULT_MAX_SESSION_COUNT_PER_ALLOWED_TIME);
+            TIMING_SESSION_COALESCING_DURATION_MS = mParser.getLong(
+                    KEY_TIMING_SESSION_COALESCING_DURATION_MS,
+                    DEFAULT_TIMING_SESSION_COALESCING_DURATION_MS);
 
             updateConstants();
         }
@@ -2071,11 +2268,48 @@
                     mMaxBucketJobCounts[RARE_INDEX] = newRareMaxJobCount;
                     changed = true;
                 }
+                int newMaxSessionCountPerAllowedPeriod = Math.max(10,
+                        MAX_SESSION_COUNT_PER_ALLOWED_TIME);
+                if (mMaxSessionCountPerAllowedTime != newMaxSessionCountPerAllowedPeriod) {
+                    mMaxSessionCountPerAllowedTime = newMaxSessionCountPerAllowedPeriod;
+                    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();
                         }
                     });
@@ -2100,6 +2334,14 @@
             pw.printPair(KEY_MAX_JOB_COUNT_RARE, MAX_JOB_COUNT_RARE).println();
             pw.printPair(KEY_MAX_JOB_COUNT_PER_ALLOWED_TIME, MAX_JOB_COUNT_PER_ALLOWED_TIME)
                     .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_ALLOWED_TIME, MAX_SESSION_COUNT_PER_ALLOWED_TIME)
+                    .println();
+            pw.printPair(KEY_TIMING_SESSION_COALESCING_DURATION_MS,
+                    TIMING_SESSION_COALESCING_DURATION_MS).println();
             pw.decreaseIndent();
         }
 
@@ -2125,6 +2367,18 @@
             proto.write(ConstantsProto.QuotaController.MAX_JOB_COUNT_RARE, MAX_JOB_COUNT_RARE);
             proto.write(ConstantsProto.QuotaController.MAX_JOB_COUNT_PER_ALLOWED_TIME,
                     MAX_JOB_COUNT_PER_ALLOWED_TIME);
+            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_ALLOWED_TIME,
+                    MAX_SESSION_COUNT_PER_ALLOWED_TIME);
+            proto.write(ConstantsProto.QuotaController.TIMING_SESSION_COALESCING_DURATION_MS,
+                    TIMING_SESSION_COALESCING_DURATION_MS);
             proto.end(qcToken);
         }
     }
@@ -2144,6 +2398,12 @@
 
     @VisibleForTesting
     @NonNull
+    int[] getBucketMaxSessionCounts() {
+        return mMaxBucketSessionCounts;
+    }
+
+    @VisibleForTesting
+    @NonNull
     long[] getBucketWindowSizes() {
         return mBucketPeriodsMs;
     }
@@ -2176,6 +2436,16 @@
     }
 
     @VisibleForTesting
+    long getTimingSessionCoalescingDurationMs() {
+        return mTimingSessionCoalescingDurationMs;
+    }
+
+    @VisibleForTesting
+    int getMaxSessionCountPerAllowedTime() {
+        return mMaxSessionCountPerAllowedTime;
+    }
+
+    @VisibleForTesting
     @Nullable
     List<TimingSession> getTimingSessions(int userId, String packageName) {
         return mTimingSessions.get(userId, packageName);
@@ -2401,6 +2671,9 @@
                                 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.QUOTA_CUTOFF_TIME_ELAPSED,
                                 es.quotaCutoffTimeElapsed);
                         proto.write(
@@ -2409,6 +2682,12 @@
                         proto.write(
                                 StateControllerProto.QuotaController.ExecutionStats.JOB_COUNT_IN_ALLOWED_TIME,
                                 es.jobCountInAllowedTime);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.SESSION_COUNT_EXPIRATION_TIME_ELAPSED,
+                                es.sessionCountExpirationTimeElapsed);
+                        proto.write(
+                                StateControllerProto.QuotaController.ExecutionStats.SESSION_COUNT_IN_ALLOWED_TIME,
+                                es.sessionCountInAllowedTime);
                         proto.end(esToken);
                     }
                 }
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 2e7283c..4e89357 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
@@ -469,6 +469,7 @@
         expectedStats.bgJobCountInWindow = 0;
         expectedStats.executionTimeInMaxPeriodMs = 22 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 15;
+        expectedStats.sessionCountInWindow = 0;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
         assertEquals(expectedStats, inputStats);
 
@@ -479,6 +480,7 @@
         expectedStats.bgJobCountInWindow = 3;
         expectedStats.executionTimeInMaxPeriodMs = 22 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 15;
+        expectedStats.sessionCountInWindow = 1;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
         assertEquals(expectedStats, inputStats);
 
@@ -490,6 +492,7 @@
         expectedStats.bgJobCountInWindow = 3;
         expectedStats.executionTimeInMaxPeriodMs = 22 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 15;
+        expectedStats.sessionCountInWindow = 1;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
         assertEquals(expectedStats, inputStats);
 
@@ -501,6 +504,7 @@
         expectedStats.bgJobCountInWindow = 3;
         expectedStats.executionTimeInMaxPeriodMs = 22 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 15;
+        expectedStats.sessionCountInWindow = 1;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
         assertEquals(expectedStats, inputStats);
 
@@ -511,6 +515,7 @@
         expectedStats.bgJobCountInWindow = 4;
         expectedStats.executionTimeInMaxPeriodMs = 22 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 15;
+        expectedStats.sessionCountInWindow = 2;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
         assertEquals(expectedStats, inputStats);
 
@@ -522,6 +527,7 @@
         expectedStats.bgJobCountInWindow = 5;
         expectedStats.executionTimeInMaxPeriodMs = 22 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 15;
+        expectedStats.sessionCountInWindow = 3;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
         assertEquals(expectedStats, inputStats);
 
@@ -532,6 +538,7 @@
         expectedStats.bgJobCountInWindow = 10;
         expectedStats.executionTimeInMaxPeriodMs = 22 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 15;
+        expectedStats.sessionCountInWindow = 4;
         expectedStats.quotaCutoffTimeElapsed = now - (2 * HOUR_IN_MILLIS - MINUTE_IN_MILLIS)
                 + mQcConstants.IN_QUOTA_BUFFER_MS;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
@@ -545,6 +552,7 @@
         expectedStats.bgJobCountInWindow = 10;
         expectedStats.executionTimeInMaxPeriodMs = 22 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 15;
+        expectedStats.sessionCountInWindow = 4;
         expectedStats.quotaCutoffTimeElapsed = now - (2 * HOUR_IN_MILLIS - MINUTE_IN_MILLIS)
                 + mQcConstants.IN_QUOTA_BUFFER_MS;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
@@ -558,6 +566,7 @@
         expectedStats.bgJobCountInWindow = 15;
         expectedStats.executionTimeInMaxPeriodMs = 22 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 15;
+        expectedStats.sessionCountInWindow = 5;
         expectedStats.quotaCutoffTimeElapsed = now - (2 * HOUR_IN_MILLIS - MINUTE_IN_MILLIS)
                 + mQcConstants.IN_QUOTA_BUFFER_MS;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
@@ -575,6 +584,7 @@
         expectedStats.bgJobCountInWindow = 15;
         expectedStats.executionTimeInMaxPeriodMs = 23 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 18;
+        expectedStats.sessionCountInWindow = 5;
         expectedStats.quotaCutoffTimeElapsed = now - (2 * HOUR_IN_MILLIS - MINUTE_IN_MILLIS)
                 + mQcConstants.IN_QUOTA_BUFFER_MS;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
@@ -585,12 +595,13 @@
                         createTimingSession(now - (24 * HOUR_IN_MILLIS + MINUTE_IN_MILLIS),
                                 2 * MINUTE_IN_MILLIS, 2));
         inputStats.windowSizeMs = expectedStats.windowSizeMs = 8 * HOUR_IN_MILLIS;
-        // Invalid time is now since the earlist session straddles the max period cutoff time.
+        // Invalid time is now since the earliest session straddles the max period cutoff time.
         expectedStats.expirationTimeElapsed = now;
         expectedStats.executionTimeInWindowMs = 22 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInWindow = 15;
         expectedStats.executionTimeInMaxPeriodMs = 24 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 20;
+        expectedStats.sessionCountInWindow = 5;
         expectedStats.quotaCutoffTimeElapsed = now - (2 * HOUR_IN_MILLIS - MINUTE_IN_MILLIS)
                 + mQcConstants.IN_QUOTA_BUFFER_MS;
         mQuotaController.updateExecutionStatsLocked(0, "com.android.test", inputStats);
@@ -621,6 +632,7 @@
         expectedStats.bgJobCountInWindow = 5;
         expectedStats.executionTimeInMaxPeriodMs = 33 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 20;
+        expectedStats.sessionCountInWindow = 1;
         assertEquals(expectedStats,
                 mQuotaController.getExecutionStatsLocked(0, "com.android.test", ACTIVE_INDEX));
 
@@ -631,6 +643,7 @@
         expectedStats.bgJobCountInWindow = 10;
         expectedStats.executionTimeInMaxPeriodMs = 33 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 20;
+        expectedStats.sessionCountInWindow = 2;
         expectedStats.quotaCutoffTimeElapsed = now - (2 * HOUR_IN_MILLIS - 3 * MINUTE_IN_MILLIS)
                 + mQcConstants.IN_QUOTA_BUFFER_MS;
         assertEquals(expectedStats,
@@ -643,6 +656,7 @@
         expectedStats.bgJobCountInWindow = 15;
         expectedStats.executionTimeInMaxPeriodMs = 33 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 20;
+        expectedStats.sessionCountInWindow = 3;
         expectedStats.quotaCutoffTimeElapsed = now - (2 * HOUR_IN_MILLIS - 3 * MINUTE_IN_MILLIS)
                 + mQcConstants.IN_QUOTA_BUFFER_MS;
         assertEquals(expectedStats,
@@ -655,6 +669,7 @@
         expectedStats.bgJobCountInWindow = 20;
         expectedStats.executionTimeInMaxPeriodMs = 33 * MINUTE_IN_MILLIS;
         expectedStats.bgJobCountInMaxPeriod = 20;
+        expectedStats.sessionCountInWindow = 4;
         expectedStats.quotaCutoffTimeElapsed = now - (2 * HOUR_IN_MILLIS - 3 * MINUTE_IN_MILLIS)
                 + mQcConstants.IN_QUOTA_BUFFER_MS;
         assertEquals(expectedStats,
@@ -662,10 +677,153 @@
     }
 
     /**
+     * Tests that getExecutionStatsLocked returns the correct timing session stats when coalescing.
+     */
+    @Test
+    public void testGetExecutionStatsLocked_CoalescingSessions() {
+        for (int i = 0; i < 10; ++i) {
+            mQuotaController.saveTimingSession(0, "com.android.test",
+                    createTimingSession(
+                            JobSchedulerService.sElapsedRealtimeClock.millis(),
+                            5 * MINUTE_IN_MILLIS, 5));
+            advanceElapsedClock(5 * MINUTE_IN_MILLIS);
+            advanceElapsedClock(5 * MINUTE_IN_MILLIS);
+            for (int j = 0; j < 5; ++j) {
+                mQuotaController.saveTimingSession(0, "com.android.test",
+                        createTimingSession(
+                                JobSchedulerService.sElapsedRealtimeClock.millis(),
+                                MINUTE_IN_MILLIS, 2));
+                advanceElapsedClock(MINUTE_IN_MILLIS);
+                advanceElapsedClock(54 * SECOND_IN_MILLIS);
+                mQuotaController.saveTimingSession(0, "com.android.test",
+                        createTimingSession(
+                                JobSchedulerService.sElapsedRealtimeClock.millis(), 500, 1));
+                advanceElapsedClock(500);
+                advanceElapsedClock(400);
+                mQuotaController.saveTimingSession(0, "com.android.test",
+                        createTimingSession(
+                                JobSchedulerService.sElapsedRealtimeClock.millis(), 100, 1));
+                advanceElapsedClock(100);
+                advanceElapsedClock(5 * SECOND_IN_MILLIS);
+            }
+            advanceElapsedClock(40 * MINUTE_IN_MILLIS);
+        }
+
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = 0;
+        mQcConstants.updateConstants();
+
+        mQuotaController.invalidateAllExecutionStatsLocked();
+        assertEquals(0, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", ACTIVE_INDEX).sessionCountInWindow);
+        assertEquals(32, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", WORKING_INDEX).sessionCountInWindow);
+        assertEquals(128, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", FREQUENT_INDEX).sessionCountInWindow);
+        assertEquals(160, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", RARE_INDEX).sessionCountInWindow);
+
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = 500;
+        mQcConstants.updateConstants();
+
+        mQuotaController.invalidateAllExecutionStatsLocked();
+        assertEquals(0, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", ACTIVE_INDEX).sessionCountInWindow);
+        assertEquals(22, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", WORKING_INDEX).sessionCountInWindow);
+        assertEquals(88, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", FREQUENT_INDEX).sessionCountInWindow);
+        assertEquals(110, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", RARE_INDEX).sessionCountInWindow);
+
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = 1000;
+        mQcConstants.updateConstants();
+
+        mQuotaController.invalidateAllExecutionStatsLocked();
+        assertEquals(0, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", ACTIVE_INDEX).sessionCountInWindow);
+        assertEquals(22, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", WORKING_INDEX).sessionCountInWindow);
+        assertEquals(88, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", FREQUENT_INDEX).sessionCountInWindow);
+        assertEquals(110, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", RARE_INDEX).sessionCountInWindow);
+
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = 5 * SECOND_IN_MILLIS;
+        mQcConstants.updateConstants();
+
+        mQuotaController.invalidateAllExecutionStatsLocked();
+        assertEquals(0, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", ACTIVE_INDEX).sessionCountInWindow);
+        assertEquals(14, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", WORKING_INDEX).sessionCountInWindow);
+        assertEquals(56, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", FREQUENT_INDEX).sessionCountInWindow);
+        assertEquals(70, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", RARE_INDEX).sessionCountInWindow);
+
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = MINUTE_IN_MILLIS;
+        mQcConstants.updateConstants();
+
+        mQuotaController.invalidateAllExecutionStatsLocked();
+        assertEquals(0, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", ACTIVE_INDEX).sessionCountInWindow);
+        assertEquals(4, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", WORKING_INDEX).sessionCountInWindow);
+        assertEquals(16, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", FREQUENT_INDEX).sessionCountInWindow);
+        assertEquals(20, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", RARE_INDEX).sessionCountInWindow);
+
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = 5 * MINUTE_IN_MILLIS;
+        mQcConstants.updateConstants();
+
+        mQuotaController.invalidateAllExecutionStatsLocked();
+        assertEquals(0, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", ACTIVE_INDEX).sessionCountInWindow);
+        assertEquals(2, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", WORKING_INDEX).sessionCountInWindow);
+        assertEquals(8, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", FREQUENT_INDEX).sessionCountInWindow);
+        assertEquals(10, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", RARE_INDEX).sessionCountInWindow);
+
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = 15 * MINUTE_IN_MILLIS;
+        mQcConstants.updateConstants();
+
+        mQuotaController.invalidateAllExecutionStatsLocked();
+        assertEquals(0, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", ACTIVE_INDEX).sessionCountInWindow);
+        assertEquals(2, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", WORKING_INDEX).sessionCountInWindow);
+        assertEquals(8, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", FREQUENT_INDEX).sessionCountInWindow);
+        assertEquals(10, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", RARE_INDEX).sessionCountInWindow);
+
+        // QuotaController caps the duration at 15 minutes, so there shouldn't be any difference
+        // between an hour and 15 minutes.
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = HOUR_IN_MILLIS;
+        mQcConstants.updateConstants();
+
+        mQuotaController.invalidateAllExecutionStatsLocked();
+        assertEquals(0, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", ACTIVE_INDEX).sessionCountInWindow);
+        assertEquals(2, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", WORKING_INDEX).sessionCountInWindow);
+        assertEquals(8, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", FREQUENT_INDEX).sessionCountInWindow);
+        assertEquals(10, mQuotaController.getExecutionStatsLocked(
+                0, "com.android.test", RARE_INDEX).sessionCountInWindow);
+    }
+
+    /**
      * Tests that getExecutionStatsLocked properly caches the stats and returns the cached object.
      */
     @Test
     public void testGetExecutionStatsLocked_Caching() {
+        spyOn(mQuotaController);
+        doNothing().when(mQuotaController).invalidateAllExecutionStatsLocked();
+
         final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
         mQuotaController.saveTimingSession(0, "com.android.test",
                 createTimingSession(now - (23 * HOUR_IN_MILLIS), 10 * MINUTE_IN_MILLIS, 5));
@@ -697,6 +855,7 @@
         expectedStats.bgJobCountInWindow = originalStatsActive.bgJobCountInWindow;
         expectedStats.executionTimeInMaxPeriodMs = originalStatsActive.executionTimeInMaxPeriodMs;
         expectedStats.bgJobCountInMaxPeriod = originalStatsActive.bgJobCountInMaxPeriod;
+        expectedStats.sessionCountInWindow = originalStatsActive.sessionCountInWindow;
         expectedStats.quotaCutoffTimeElapsed = originalStatsActive.quotaCutoffTimeElapsed;
         final ExecutionStats newStatsActive = mQuotaController.getExecutionStatsLocked(0,
                 "com.android.test", ACTIVE_INDEX);
@@ -708,6 +867,7 @@
         expectedStats.expirationTimeElapsed = originalStatsWorking.expirationTimeElapsed;
         expectedStats.executionTimeInWindowMs = originalStatsWorking.executionTimeInWindowMs;
         expectedStats.bgJobCountInWindow = originalStatsWorking.bgJobCountInWindow;
+        expectedStats.sessionCountInWindow = originalStatsWorking.sessionCountInWindow;
         expectedStats.quotaCutoffTimeElapsed = originalStatsWorking.quotaCutoffTimeElapsed;
         final ExecutionStats newStatsWorking = mQuotaController.getExecutionStatsLocked(0,
                 "com.android.test", WORKING_INDEX);
@@ -718,6 +878,7 @@
         expectedStats.expirationTimeElapsed = originalStatsFrequent.expirationTimeElapsed;
         expectedStats.executionTimeInWindowMs = originalStatsFrequent.executionTimeInWindowMs;
         expectedStats.bgJobCountInWindow = originalStatsFrequent.bgJobCountInWindow;
+        expectedStats.sessionCountInWindow = originalStatsFrequent.sessionCountInWindow;
         expectedStats.quotaCutoffTimeElapsed = originalStatsFrequent.quotaCutoffTimeElapsed;
         final ExecutionStats newStatsFrequent = mQuotaController.getExecutionStatsLocked(0,
                 "com.android.test", FREQUENT_INDEX);
@@ -728,6 +889,7 @@
         expectedStats.expirationTimeElapsed = originalStatsRare.expirationTimeElapsed;
         expectedStats.executionTimeInWindowMs = originalStatsRare.executionTimeInWindowMs;
         expectedStats.bgJobCountInWindow = originalStatsRare.bgJobCountInWindow;
+        expectedStats.sessionCountInWindow = originalStatsRare.sessionCountInWindow;
         expectedStats.quotaCutoffTimeElapsed = originalStatsRare.quotaCutoffTimeElapsed;
         final ExecutionStats newStatsRare = mQuotaController.getExecutionStatsLocked(0,
                 "com.android.test", RARE_INDEX);
@@ -1057,6 +1219,37 @@
     }
 
     @Test
+    public void testIsWithinQuotaLocked_TimingSession() {
+        setDischarging();
+        final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+        mQcConstants.MAX_SESSION_COUNT_RARE = 3;
+        mQcConstants.MAX_SESSION_COUNT_FREQUENT = 4;
+        mQcConstants.MAX_SESSION_COUNT_WORKING = 5;
+        mQcConstants.MAX_SESSION_COUNT_ACTIVE = 6;
+        mQcConstants.updateConstants();
+
+        for (int i = 0; i < 7; ++i) {
+            mQuotaController.saveTimingSession(0, "com.android.test",
+                    createTimingSession(now - ((10 - i) * MINUTE_IN_MILLIS), 30 * SECOND_IN_MILLIS,
+                            2));
+            mQuotaController.incrementJobCount(0, "com.android.test", 2);
+
+            assertEquals("Rare has incorrect quota status with " + (i + 1) + " sessions",
+                    i < 2,
+                    mQuotaController.isWithinQuotaLocked(0, "com.android.test", RARE_INDEX));
+            assertEquals("Frequent has incorrect quota status with " + (i + 1) + " sessions",
+                    i < 3,
+                    mQuotaController.isWithinQuotaLocked(0, "com.android.test", FREQUENT_INDEX));
+            assertEquals("Working has incorrect quota status with " + (i + 1) + " sessions",
+                    i < 4,
+                    mQuotaController.isWithinQuotaLocked(0, "com.android.test", WORKING_INDEX));
+            assertEquals("Active has incorrect quota status with " + (i + 1) + " sessions",
+                    i < 5,
+                    mQuotaController.isWithinQuotaLocked(0, "com.android.test", ACTIVE_INDEX));
+        }
+    }
+
+    @Test
     public void testMaybeScheduleCleanupAlarmLocked() {
         // No sessions saved yet.
         mQuotaController.maybeScheduleCleanupAlarmLocked();
@@ -1244,6 +1437,10 @@
         // Rare window size is 24 hours.
         final int standbyBucket = RARE_INDEX;
 
+        // Prevent timing session throttling from affecting the test.
+        mQcConstants.MAX_SESSION_COUNT_RARE = 50;
+        mQcConstants.updateConstants();
+
         // No sessions saved yet.
         mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
         verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
@@ -1536,6 +1733,12 @@
         mQcConstants.MAX_JOB_COUNT_FREQUENT = 3000;
         mQcConstants.MAX_JOB_COUNT_RARE = 2000;
         mQcConstants.MAX_JOB_COUNT_PER_ALLOWED_TIME = 500;
+        mQcConstants.MAX_SESSION_COUNT_ACTIVE = 500;
+        mQcConstants.MAX_SESSION_COUNT_WORKING = 400;
+        mQcConstants.MAX_SESSION_COUNT_FREQUENT = 300;
+        mQcConstants.MAX_SESSION_COUNT_RARE = 200;
+        mQcConstants.MAX_SESSION_COUNT_PER_ALLOWED_TIME = 50;
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = 10 * SECOND_IN_MILLIS;
 
         mQcConstants.updateConstants();
 
@@ -1552,6 +1755,13 @@
         assertEquals(4000, mQuotaController.getBucketMaxJobCounts()[WORKING_INDEX]);
         assertEquals(3000, mQuotaController.getBucketMaxJobCounts()[FREQUENT_INDEX]);
         assertEquals(2000, mQuotaController.getBucketMaxJobCounts()[RARE_INDEX]);
+        assertEquals(50, mQuotaController.getMaxSessionCountPerAllowedTime());
+        assertEquals(500, mQuotaController.getBucketMaxSessionCounts()[ACTIVE_INDEX]);
+        assertEquals(400, mQuotaController.getBucketMaxSessionCounts()[WORKING_INDEX]);
+        assertEquals(300, mQuotaController.getBucketMaxSessionCounts()[FREQUENT_INDEX]);
+        assertEquals(200, mQuotaController.getBucketMaxSessionCounts()[RARE_INDEX]);
+        assertEquals(10 * SECOND_IN_MILLIS,
+                mQuotaController.getTimingSessionCoalescingDurationMs());
     }
 
     @Test
@@ -1569,6 +1779,12 @@
         mQcConstants.MAX_JOB_COUNT_FREQUENT = 1;
         mQcConstants.MAX_JOB_COUNT_RARE = 1;
         mQcConstants.MAX_JOB_COUNT_PER_ALLOWED_TIME = 0;
+        mQcConstants.MAX_SESSION_COUNT_ACTIVE = -1;
+        mQcConstants.MAX_SESSION_COUNT_WORKING = 1;
+        mQcConstants.MAX_SESSION_COUNT_FREQUENT = 2;
+        mQcConstants.MAX_SESSION_COUNT_RARE = 1;
+        mQcConstants.MAX_SESSION_COUNT_PER_ALLOWED_TIME = 0;
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = -1;
 
         mQcConstants.updateConstants();
 
@@ -1584,6 +1800,12 @@
         assertEquals(100, mQuotaController.getBucketMaxJobCounts()[WORKING_INDEX]);
         assertEquals(100, mQuotaController.getBucketMaxJobCounts()[FREQUENT_INDEX]);
         assertEquals(100, mQuotaController.getBucketMaxJobCounts()[RARE_INDEX]);
+        assertEquals(10, mQuotaController.getMaxSessionCountPerAllowedTime());
+        assertEquals(3, mQuotaController.getBucketMaxSessionCounts()[ACTIVE_INDEX]);
+        assertEquals(3, mQuotaController.getBucketMaxSessionCounts()[WORKING_INDEX]);
+        assertEquals(3, mQuotaController.getBucketMaxSessionCounts()[FREQUENT_INDEX]);
+        assertEquals(3, mQuotaController.getBucketMaxSessionCounts()[RARE_INDEX]);
+        assertEquals(0, mQuotaController.getTimingSessionCoalescingDurationMs());
 
         // Test larger than a day. Controller should cap at one day.
         mQcConstants.ALLOWED_TIME_PER_PERIOD_MS = 25 * HOUR_IN_MILLIS;
@@ -1593,6 +1815,7 @@
         mQcConstants.WINDOW_SIZE_FREQUENT_MS = 25 * HOUR_IN_MILLIS;
         mQcConstants.WINDOW_SIZE_RARE_MS = 25 * HOUR_IN_MILLIS;
         mQcConstants.MAX_EXECUTION_TIME_MS = 25 * HOUR_IN_MILLIS;
+        mQcConstants.TIMING_SESSION_COALESCING_DURATION_MS = 25 * HOUR_IN_MILLIS;
 
         mQcConstants.updateConstants();
 
@@ -1603,6 +1826,8 @@
         assertEquals(24 * HOUR_IN_MILLIS, mQuotaController.getBucketWindowSizes()[FREQUENT_INDEX]);
         assertEquals(24 * HOUR_IN_MILLIS, mQuotaController.getBucketWindowSizes()[RARE_INDEX]);
         assertEquals(24 * HOUR_IN_MILLIS, mQuotaController.getMaxExecutionTimeMs());
+        assertEquals(15 * MINUTE_IN_MILLIS,
+                mQuotaController.getTimingSessionCoalescingDurationMs());
     }
 
     /** Tests that TimingSessions aren't saved when the device is charging. */
@@ -2205,7 +2430,11 @@
         spyOn(mQuotaController);
         doNothing().when(mQuotaController).maybeScheduleCleanupAlarmLocked();
 
-        final long start = JobSchedulerService.sElapsedRealtimeClock.millis();
+        // Essentially disable session throttling.
+        mQcConstants.MAX_SESSION_COUNT_WORKING =
+                mQcConstants.MAX_SESSION_COUNT_PER_ALLOWED_TIME = Integer.MAX_VALUE;
+        mQcConstants.updateConstants();
+
         final int standbyBucket = WORKING_INDEX;
         setProcessState(ActivityManager.PROCESS_STATE_IMPORTANT_BACKGROUND);
 
@@ -2217,6 +2446,7 @@
         // Ran jobs up to the job limit. All of them should be allowed to run.
         for (int i = 0; i < mQcConstants.MAX_JOB_COUNT_PER_ALLOWED_TIME; ++i) {
             JobStatus job = createJobStatus("testStartAlarmScheduled_JobCount_AllowedTime", i);
+            setStandbyBucket(WORKING_INDEX, job);
             mQuotaController.maybeStartTrackingJobLocked(job, null);
             assertTrue(job.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
             mQuotaController.prepareForExecutionLocked(job);
@@ -2230,6 +2460,7 @@
         // The app is now out of job count quota
         JobStatus throttledJob = createJobStatus(
                 "testStartAlarmScheduled_JobCount_AllowedTime", 42);
+        setStandbyBucket(WORKING_INDEX, throttledJob);
         mQuotaController.maybeStartTrackingJobLocked(throttledJob, null);
         assertFalse(throttledJob.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
 
@@ -2240,4 +2471,61 @@
         verify(mAlarmManager, times(1))
                 .set(anyInt(), eq(expectedWorkingAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
     }
+
+    /**
+     * Tests that the start alarm is properly scheduled when a job has been throttled due to the job
+     * count quota.
+     */
+    @Test
+    public void testStartAlarmScheduled_TimingSessionCount_AllowedTime() {
+        // saveTimingSession calls maybeScheduleCleanupAlarmLocked which interferes with these tests
+        // because it schedules an alarm too. Prevent it from doing so.
+        spyOn(mQuotaController);
+        doNothing().when(mQuotaController).maybeScheduleCleanupAlarmLocked();
+
+        // Essentially disable job count throttling.
+        mQcConstants.MAX_JOB_COUNT_FREQUENT =
+                mQcConstants.MAX_JOB_COUNT_PER_ALLOWED_TIME = Integer.MAX_VALUE;
+        // Make sure throttling is because of COUNT_PER_ALLOWED_TIME.
+        mQcConstants.MAX_SESSION_COUNT_FREQUENT =
+                mQcConstants.MAX_SESSION_COUNT_PER_ALLOWED_TIME + 1;
+        mQcConstants.updateConstants();
+
+        final int standbyBucket = FREQUENT_INDEX;
+        setProcessState(ActivityManager.PROCESS_STATE_IMPORTANT_BACKGROUND);
+
+        // No sessions saved yet.
+        mQuotaController.maybeScheduleStartAlarmLocked(SOURCE_USER_ID, SOURCE_PACKAGE,
+                standbyBucket);
+        verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+        // Ran jobs up to the job limit. All of them should be allowed to run.
+        for (int i = 0; i < mQcConstants.MAX_SESSION_COUNT_PER_ALLOWED_TIME; ++i) {
+            JobStatus job = createJobStatus(
+                    "testStartAlarmScheduled_TimingSessionCount_AllowedTime", i);
+            setStandbyBucket(FREQUENT_INDEX, job);
+            mQuotaController.maybeStartTrackingJobLocked(job, null);
+            assertTrue("Constraint not satisfied for job #" + (i + 1),
+                    job.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
+            mQuotaController.prepareForExecutionLocked(job);
+            advanceElapsedClock(SECOND_IN_MILLIS);
+            mQuotaController.maybeStopTrackingJobLocked(job, null, false);
+            advanceElapsedClock(SECOND_IN_MILLIS);
+        }
+        // Start alarm shouldn't have been scheduled since the app was in quota up until this point.
+        verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+        // The app is now out of job count quota
+        JobStatus throttledJob = createJobStatus(
+                "testStartAlarmScheduled_TimingSessionCount_AllowedTime", 42);
+        mQuotaController.maybeStartTrackingJobLocked(throttledJob, null);
+        assertFalse(throttledJob.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
+
+        ExecutionStats stats = mQuotaController.getExecutionStatsLocked(SOURCE_USER_ID,
+                SOURCE_PACKAGE, standbyBucket);
+        final long expectedWorkingAlarmTime =
+                stats.sessionCountExpirationTimeElapsed + mQcConstants.ALLOWED_TIME_PER_PERIOD_MS;
+        verify(mAlarmManager, times(1))
+                .set(anyInt(), eq(expectedWorkingAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+    }
 }