summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--core/proto/android/server/jobscheduler.proto95
-rw-r--r--services/core/java/com/android/server/job/JobSchedulerService.java264
-rw-r--r--services/core/java/com/android/server/job/controllers/JobStatus.java70
-rw-r--r--services/core/java/com/android/server/job/controllers/QuotaController.java1299
-rw-r--r--services/core/java/com/android/server/job/controllers/StateController.java9
-rw-r--r--services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java842
6 files changed, 2498 insertions, 81 deletions
diff --git a/core/proto/android/server/jobscheduler.proto b/core/proto/android/server/jobscheduler.proto
index e83a2bfac77a..231caabe0335 100644
--- a/core/proto/android/server/jobscheduler.proto
+++ b/core/proto/android/server/jobscheduler.proto
@@ -215,6 +215,34 @@ message ConstantsProto {
// The fraction of a prefetch job's running window that must pass before
// we consider matching it against a metered network.
optional double conn_prefetch_relax_frac = 22;
+ // Whether to use heartbeats or rolling window for quota management. True
+ // will use heartbeats, false will use a rolling window.
+ optional bool use_heartbeats = 23;
+
+ message QuotaController {
+ // How much time each app will have to run jobs within their standby bucket window.
+ optional int64 allowed_time_per_period_ms = 1;
+ // How much time the package should have before transitioning from out-of-quota to in-quota.
+ // This should not affect processing if the package is already in-quota.
+ optional int64 in_quota_buffer_ms = 2;
+ // The quota window size of the particular standby bucket. Apps in this standby bucket are
+ // expected to run only {@link QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS} within the past
+ // WINDOW_SIZE_MS.
+ optional int64 active_window_size_ms = 3;
+ // The quota window size of the particular standby bucket. Apps in this standby bucket are
+ // expected to run only {@link QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS} within the past
+ // WINDOW_SIZE_MS.
+ optional int64 working_window_size_ms = 4;
+ // The quota window size of the particular standby bucket. Apps in this standby bucket are
+ // expected to run only {@link QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS} within the past
+ // WINDOW_SIZE_MS.
+ optional int64 frequent_window_size_ms = 5;
+ // The quota window size of the particular standby bucket. Apps in this standby bucket are
+ // expected to run only {@link QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS} within the past
+ // WINDOW_SIZE_MS.
+ optional int64 rare_window_size_ms = 6;
+ }
+ optional QuotaController quota_controller = 24;
}
message StateControllerProto {
@@ -357,6 +385,65 @@ message StateControllerProto {
}
repeated TrackedJob tracked_jobs = 2;
}
+ message QuotaController {
+ option (.android.msg_privacy).dest = DEST_AUTOMATIC;
+
+ optional bool is_charging = 1;
+ optional bool is_in_parole = 2;
+
+ message TrackedJob {
+ option (.android.msg_privacy).dest = DEST_AUTOMATIC;
+
+ optional JobStatusShortInfoProto info = 1;
+ optional int32 source_uid = 2;
+ optional JobStatusDumpProto.Bucket effective_standby_bucket = 3;
+ optional bool has_quota = 4;
+ // The amount of time that this job has remaining in its quota. This
+ // can be negative if the job is out of quota.
+ optional int64 remaining_quota_ms = 5;
+ }
+ repeated TrackedJob tracked_jobs = 3;
+
+ message Package {
+ option (.android.msg_privacy).dest = DEST_AUTOMATIC;
+
+ optional int32 user_id = 1;
+ optional string name = 2;
+ }
+
+ message TimingSession {
+ option (.android.msg_privacy).dest = DEST_AUTOMATIC;
+
+ optional int64 start_time_elapsed = 1;
+ optional int64 end_time_elapsed = 2;
+ optional int32 job_count = 3;
+ }
+
+ message Timer {
+ option (.android.msg_privacy).dest = DEST_AUTOMATIC;
+
+ optional Package pkg = 1;
+ // True if the Timer is actively tracking jobs.
+ optional bool is_active = 2;
+ // The time this timer last became active. Only valid if is_active is true.
+ optional int64 start_time_elapsed = 3;
+ // How many are currently running. Valid only if the device is_active is true.
+ optional int32 job_count = 4;
+ // All of the jobs that the Timer is currently tracking.
+ repeated JobStatusShortInfoProto running_jobs = 5;
+ }
+
+ message PackageStats {
+ option (.android.msg_privacy).dest = DEST_AUTOMATIC;
+
+ optional Package pkg = 1;
+
+ optional Timer timer = 2;
+
+ repeated TimingSession saved_sessions = 3;
+ }
+ repeated PackageStats package_stats = 4;
+ }
message StorageController {
option (.android.msg_privacy).dest = DEST_AUTOMATIC;
@@ -403,8 +490,10 @@ message StateControllerProto {
ContentObserverController content_observer = 4;
DeviceIdleJobsController device_idle = 5;
IdleController idle = 6;
+ QuotaController quota = 9;
StorageController storage = 7;
TimeController time = 8;
+ // Next tag: 10
}
}
@@ -603,11 +692,13 @@ message JobStatusDumpProto {
CONSTRAINT_CONNECTIVITY = 7;
CONSTRAINT_CONTENT_TRIGGER = 8;
CONSTRAINT_DEVICE_NOT_DOZING = 9;
+ CONSTRAINT_WITHIN_QUOTA = 10;
}
repeated Constraint required_constraints = 7;
repeated Constraint satisfied_constraints = 8;
repeated Constraint unsatisfied_constraints = 9;
optional bool is_doze_whitelisted = 10;
+ optional bool is_uid_active = 26;
message ImplicitConstraints {
// The device isn't Dozing or this job will be in the foreground. This
@@ -627,6 +718,7 @@ message JobStatusDumpProto {
TRACKING_IDLE = 3;
TRACKING_STORAGE = 4;
TRACKING_TIME = 5;
+ TRACKING_QUOTA = 6;
}
// Controllers that are currently tracking the job.
repeated TrackingController tracking_controllers = 11;
@@ -660,6 +752,7 @@ message JobStatusDumpProto {
NEVER = 4;
}
optional Bucket standby_bucket = 17;
+ optional bool is_exempted_from_app_standby = 27;
optional int64 enqueue_duration_ms = 18;
// Can be negative if the earliest runtime deadline has passed.
@@ -674,5 +767,5 @@ message JobStatusDumpProto {
optional int64 internal_flags = 24;
- // Next tag: 26
+ // Next tag: 28
}
diff --git a/services/core/java/com/android/server/job/JobSchedulerService.java b/services/core/java/com/android/server/job/JobSchedulerService.java
index b3f0629ea2d3..ea295de5909f 100644
--- a/services/core/java/com/android/server/job/JobSchedulerService.java
+++ b/services/core/java/com/android/server/job/JobSchedulerService.java
@@ -78,7 +78,6 @@ import android.util.proto.ProtoOutputStream;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.app.IBatteryStats;
import com.android.internal.app.procstats.ProcessStats;
-import com.android.internal.os.BackgroundThread;
import com.android.internal.util.ArrayUtils;
import com.android.internal.util.DumpUtils;
import com.android.internal.util.IndentingPrintWriter;
@@ -97,6 +96,7 @@ import com.android.server.job.controllers.ContentObserverController;
import com.android.server.job.controllers.DeviceIdleJobsController;
import com.android.server.job.controllers.IdleController;
import com.android.server.job.controllers.JobStatus;
+import com.android.server.job.controllers.QuotaController;
import com.android.server.job.controllers.StateController;
import com.android.server.job.controllers.StorageController;
import com.android.server.job.controllers.TimeController;
@@ -245,11 +245,11 @@ public class JobSchedulerService extends com.android.server.SystemService
* Named indices into the STANDBY_BEATS array, for clarity in referring to
* specific buckets' bookkeeping.
*/
- static final int ACTIVE_INDEX = 0;
- static final int WORKING_INDEX = 1;
- static final int FREQUENT_INDEX = 2;
- static final int RARE_INDEX = 3;
- static final int NEVER_INDEX = 4;
+ public static final int ACTIVE_INDEX = 0;
+ public static final int WORKING_INDEX = 1;
+ public static final int FREQUENT_INDEX = 2;
+ public static final int RARE_INDEX = 3;
+ public static final int NEVER_INDEX = 4;
/**
* Bookkeeping about when jobs last run. We keep our own record in heartbeat time,
@@ -308,6 +308,10 @@ public class JobSchedulerService extends com.android.server.SystemService
try {
mConstants.updateConstantsLocked(Settings.Global.getString(mResolver,
Settings.Global.JOB_SCHEDULER_CONSTANTS));
+ for (int controller = 0; controller < mControllers.size(); controller++) {
+ final StateController sc = mControllers.get(controller);
+ sc.onConstantsUpdatedLocked();
+ }
} catch (IllegalArgumentException e) {
// Failed to parse the settings string, log this and move on
// with defaults.
@@ -315,8 +319,10 @@ public class JobSchedulerService extends com.android.server.SystemService
}
}
- // Reset the heartbeat alarm based on the new heartbeat duration
- setNextHeartbeatAlarm();
+ if (mConstants.USE_HEARTBEATS) {
+ // Reset the heartbeat alarm based on the new heartbeat duration
+ setNextHeartbeatAlarm();
+ }
}
}
@@ -352,6 +358,19 @@ public class JobSchedulerService extends com.android.server.SystemService
private static final String KEY_STANDBY_RARE_BEATS = "standby_rare_beats";
private static final String KEY_CONN_CONGESTION_DELAY_FRAC = "conn_congestion_delay_frac";
private static final String KEY_CONN_PREFETCH_RELAX_FRAC = "conn_prefetch_relax_frac";
+ private static final String KEY_USE_HEARTBEATS = "use_heartbeats";
+ private static final String KEY_QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS =
+ "qc_allowed_time_per_period_ms";
+ private static final String KEY_QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS =
+ "qc_in_quota_buffer_ms";
+ private static final String KEY_QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS =
+ "qc_window_size_active_ms";
+ private static final String KEY_QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS =
+ "qc_window_size_working_ms";
+ private static final String KEY_QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS =
+ "qc_window_size_frequent_ms";
+ private static final String KEY_QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS =
+ "qc_window_size_rare_ms";
private static final int DEFAULT_MIN_IDLE_COUNT = 1;
private static final int DEFAULT_MIN_CHARGING_COUNT = 1;
@@ -377,6 +396,19 @@ public class JobSchedulerService extends com.android.server.SystemService
private static final int DEFAULT_STANDBY_RARE_BEATS = 130; // ~ 24 hours
private static final float DEFAULT_CONN_CONGESTION_DELAY_FRAC = 0.5f;
private static final float DEFAULT_CONN_PREFETCH_RELAX_FRAC = 0.5f;
+ private static final boolean DEFAULT_USE_HEARTBEATS = true;
+ private static final long DEFAULT_QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS =
+ 10 * 60 * 1000L; // 10 minutes
+ private static final long DEFAULT_QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS =
+ 30 * 1000L; // 30 seconds
+ private static final long DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS =
+ 10 * 60 * 1000L; // 10 minutes for ACTIVE -- ACTIVE apps can run jobs at any time
+ private static final long DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS =
+ 2 * 60 * 60 * 1000L; // 2 hours
+ private static final long DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS =
+ 8 * 60 * 60 * 1000L; // 8 hours
+ private static final long DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS =
+ 24 * 60 * 60 * 1000L; // 24 hours
/**
* Minimum # of idle jobs that must be ready in order to force the JMS to schedule things
@@ -495,6 +527,54 @@ public class JobSchedulerService extends com.android.server.SystemService
* we consider matching it against a metered network.
*/
public float CONN_PREFETCH_RELAX_FRAC = DEFAULT_CONN_PREFETCH_RELAX_FRAC;
+ /**
+ * Whether to use heartbeats or rolling window for quota management. True will use
+ * heartbeats, false will use a rolling window.
+ */
+ public boolean USE_HEARTBEATS = DEFAULT_USE_HEARTBEATS;
+
+ /** How much time each app will have to run jobs within their standby bucket window. */
+ public long QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS =
+ DEFAULT_QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS;
+
+ /**
+ * How much time the package should have before transitioning from out-of-quota to in-quota.
+ * This should not affect processing if the package is already in-quota.
+ */
+ public long QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS =
+ DEFAULT_QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS;
+
+ /**
+ * The quota window size of the particular standby bucket. Apps in this standby bucket are
+ * expected to run only {@link QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS} within the past
+ * WINDOW_SIZE_MS.
+ */
+ public long QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS =
+ DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS;
+
+ /**
+ * The quota window size of the particular standby bucket. Apps in this standby bucket are
+ * expected to run only {@link QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS} within the past
+ * WINDOW_SIZE_MS.
+ */
+ public long QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS =
+ DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS;
+
+ /**
+ * The quota window size of the particular standby bucket. Apps in this standby bucket are
+ * expected to run only {@link QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS} within the past
+ * WINDOW_SIZE_MS.
+ */
+ public long QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS =
+ DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS;
+
+ /**
+ * The quota window size of the particular standby bucket. Apps in this standby bucket are
+ * expected to run only {@link QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS} within the past
+ * WINDOW_SIZE_MS.
+ */
+ public long QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS =
+ DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS;
private final KeyValueListParser mParser = new KeyValueListParser(',');
@@ -567,6 +647,25 @@ public class JobSchedulerService extends com.android.server.SystemService
DEFAULT_CONN_CONGESTION_DELAY_FRAC);
CONN_PREFETCH_RELAX_FRAC = mParser.getFloat(KEY_CONN_PREFETCH_RELAX_FRAC,
DEFAULT_CONN_PREFETCH_RELAX_FRAC);
+ USE_HEARTBEATS = mParser.getBoolean(KEY_USE_HEARTBEATS, DEFAULT_USE_HEARTBEATS);
+ QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS = mParser.getDurationMillis(
+ KEY_QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS,
+ DEFAULT_QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS);
+ QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS = mParser.getDurationMillis(
+ KEY_QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS,
+ DEFAULT_QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS);
+ QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS = mParser.getDurationMillis(
+ KEY_QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS,
+ DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS);
+ QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS = mParser.getDurationMillis(
+ KEY_QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS,
+ DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS);
+ QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS = mParser.getDurationMillis(
+ KEY_QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS,
+ DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS);
+ QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS = mParser.getDurationMillis(
+ KEY_QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS,
+ DEFAULT_QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS);
}
void dump(IndentingPrintWriter pw) {
@@ -600,6 +699,19 @@ public class JobSchedulerService extends com.android.server.SystemService
pw.println('}');
pw.printPair(KEY_CONN_CONGESTION_DELAY_FRAC, CONN_CONGESTION_DELAY_FRAC).println();
pw.printPair(KEY_CONN_PREFETCH_RELAX_FRAC, CONN_PREFETCH_RELAX_FRAC).println();
+ pw.printPair(KEY_USE_HEARTBEATS, USE_HEARTBEATS).println();
+ pw.printPair(KEY_QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS,
+ QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS).println();
+ pw.printPair(KEY_QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS,
+ QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS).println();
+ pw.printPair(KEY_QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS,
+ QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS).println();
+ pw.printPair(KEY_QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS,
+ QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS).println();
+ pw.printPair(KEY_QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS,
+ QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS).println();
+ pw.printPair(KEY_QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS,
+ QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS).println();
pw.decreaseIndent();
}
@@ -629,6 +741,23 @@ public class JobSchedulerService extends com.android.server.SystemService
}
proto.write(ConstantsProto.CONN_CONGESTION_DELAY_FRAC, CONN_CONGESTION_DELAY_FRAC);
proto.write(ConstantsProto.CONN_PREFETCH_RELAX_FRAC, CONN_PREFETCH_RELAX_FRAC);
+ proto.write(ConstantsProto.USE_HEARTBEATS, USE_HEARTBEATS);
+
+ final long qcToken = proto.start(ConstantsProto.QUOTA_CONTROLLER);
+ proto.write(ConstantsProto.QuotaController.ALLOWED_TIME_PER_PERIOD_MS,
+ QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS);
+ proto.write(ConstantsProto.QuotaController.IN_QUOTA_BUFFER_MS,
+ QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS);
+ proto.write(ConstantsProto.QuotaController.ACTIVE_WINDOW_SIZE_MS,
+ QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS);
+ proto.write(ConstantsProto.QuotaController.WORKING_WINDOW_SIZE_MS,
+ QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS);
+ proto.write(ConstantsProto.QuotaController.FREQUENT_WINDOW_SIZE_MS,
+ QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS);
+ proto.write(ConstantsProto.QuotaController.RARE_WINDOW_SIZE_MS,
+ QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS);
+ proto.end(qcToken);
+
proto.end(token);
}
}
@@ -1162,6 +1291,7 @@ public class JobSchedulerService extends com.android.server.SystemService
mControllers.add(new ContentObserverController(this));
mDeviceIdleJobsController = new DeviceIdleJobsController(this);
mControllers.add(mDeviceIdleJobsController);
+ mControllers.add(new QuotaController(this));
// If the job store determined that it can't yet reschedule persisted jobs,
// we need to start watching the clock.
@@ -1225,7 +1355,9 @@ public class JobSchedulerService extends com.android.server.SystemService
mAppStateTracker = Preconditions.checkNotNull(
LocalServices.getService(AppStateTracker.class));
- setNextHeartbeatAlarm();
+ if (mConstants.USE_HEARTBEATS) {
+ setNextHeartbeatAlarm();
+ }
// Register br for package removals and user removals.
final IntentFilter filter = new IntentFilter();
@@ -1869,6 +2001,9 @@ public class JobSchedulerService extends com.android.server.SystemService
// Intentionally does not touch the alarm timing
void advanceHeartbeatLocked(long beatsElapsed) {
+ if (!mConstants.USE_HEARTBEATS) {
+ return;
+ }
mHeartbeat += beatsElapsed;
if (DEBUG_STANDBY) {
Slog.v(TAG, "Advancing standby heartbeat by " + beatsElapsed
@@ -1904,6 +2039,9 @@ public class JobSchedulerService extends com.android.server.SystemService
void setNextHeartbeatAlarm() {
final long heartbeatLength;
synchronized (mLock) {
+ if (!mConstants.USE_HEARTBEATS) {
+ return;
+ }
heartbeatLength = mConstants.STANDBY_HEARTBEAT_TIME;
}
final long now = sElapsedRealtimeClock.millis();
@@ -1976,48 +2114,51 @@ public class JobSchedulerService extends com.android.server.SystemService
return false;
}
- // If the app is in a non-active standby bucket, make sure we've waited
- // an appropriate amount of time since the last invocation. During device-
- // wide parole, standby bucketing is ignored.
- //
- // Jobs in 'active' apps are not subject to standby, nor are jobs that are
- // specifically marked as exempt.
- if (DEBUG_STANDBY) {
- Slog.v(TAG, "isReadyToBeExecutedLocked: " + job.toShortString()
- + " parole=" + mInParole + " active=" + job.uidActive
- + " exempt=" + job.getJob().isExemptedFromAppStandby());
- }
- if (!mInParole
- && !job.uidActive
- && !job.getJob().isExemptedFromAppStandby()) {
- final int bucket = job.getStandbyBucket();
+ if (mConstants.USE_HEARTBEATS) {
+ // If the app is in a non-active standby bucket, make sure we've waited
+ // an appropriate amount of time since the last invocation. During device-
+ // wide parole, standby bucketing is ignored.
+ //
+ // Jobs in 'active' apps are not subject to standby, nor are jobs that are
+ // specifically marked as exempt.
if (DEBUG_STANDBY) {
- Slog.v(TAG, " bucket=" + bucket + " heartbeat=" + mHeartbeat
- + " next=" + mNextBucketHeartbeat[bucket]);
- }
- if (mHeartbeat < mNextBucketHeartbeat[bucket]) {
- // Only skip this job if the app is still waiting for the end of its nominal
- // bucket interval. Once it's waited that long, we let it go ahead and clear.
- // The final (NEVER) bucket is special; we never age those apps' jobs into
- // runnability.
- final long appLastRan = heartbeatWhenJobsLastRun(job);
- if (bucket >= mConstants.STANDBY_BEATS.length
- || (mHeartbeat > appLastRan
- && mHeartbeat < appLastRan + mConstants.STANDBY_BEATS[bucket])) {
- // TODO: log/trace that we're deferring the job due to bucketing if we hit this
- if (job.getWhenStandbyDeferred() == 0) {
+ Slog.v(TAG, "isReadyToBeExecutedLocked: " + job.toShortString()
+ + " parole=" + mInParole + " active=" + job.uidActive
+ + " exempt=" + job.getJob().isExemptedFromAppStandby());
+ }
+ if (!mInParole
+ && !job.uidActive
+ && !job.getJob().isExemptedFromAppStandby()) {
+ final int bucket = job.getStandbyBucket();
+ if (DEBUG_STANDBY) {
+ Slog.v(TAG, " bucket=" + bucket + " heartbeat=" + mHeartbeat
+ + " next=" + mNextBucketHeartbeat[bucket]);
+ }
+ if (mHeartbeat < mNextBucketHeartbeat[bucket]) {
+ // Only skip this job if the app is still waiting for the end of its nominal
+ // bucket interval. Once it's waited that long, we let it go ahead and clear.
+ // The final (NEVER) bucket is special; we never age those apps' jobs into
+ // runnability.
+ final long appLastRan = heartbeatWhenJobsLastRun(job);
+ if (bucket >= mConstants.STANDBY_BEATS.length
+ || (mHeartbeat > appLastRan
+ && mHeartbeat < appLastRan + mConstants.STANDBY_BEATS[bucket])) {
+ // TODO: log/trace that we're deferring the job due to bucketing if we
+ // hit this
+ if (job.getWhenStandbyDeferred() == 0) {
+ if (DEBUG_STANDBY) {
+ Slog.v(TAG, "Bucket deferral: " + mHeartbeat + " < "
+ + (appLastRan + mConstants.STANDBY_BEATS[bucket])
+ + " for " + job);
+ }
+ job.setWhenStandbyDeferred(sElapsedRealtimeClock.millis());
+ }
+ return false;
+ } else {
if (DEBUG_STANDBY) {
- Slog.v(TAG, "Bucket deferral: " + mHeartbeat + " < "
- + (appLastRan + mConstants.STANDBY_BEATS[bucket])
- + " for " + job);
+ Slog.v(TAG, "Bucket deferred job aged into runnability at "
+ + mHeartbeat + " : " + job);
}
- job.setWhenStandbyDeferred(sElapsedRealtimeClock.millis());
- }
- return false;
- } else {
- if (DEBUG_STANDBY) {
- Slog.v(TAG, "Bucket deferred job aged into runnability at "
- + mHeartbeat + " : " + job);
}
}
}
@@ -2364,32 +2505,7 @@ public class JobSchedulerService extends com.android.server.SystemService
@Override
public void onAppIdleStateChanged(final String packageName, final @UserIdInt int userId,
boolean idle, int bucket, int reason) {
- final int uid = mLocalPM.getPackageUid(packageName,
- PackageManager.MATCH_UNINSTALLED_PACKAGES, userId);
- if (uid < 0) {
- if (DEBUG_STANDBY) {
- Slog.i(TAG, "App idle state change for unknown app "
- + packageName + "/" + userId);
- }
- return;
- }
-
- final int bucketIndex = standbyBucketToBucketIndex(bucket);
- // update job bookkeeping out of band
- BackgroundThread.getHandler().post(() -> {
- if (DEBUG_STANDBY) {
- Slog.i(TAG, "Moving uid " + uid + " to bucketIndex " + bucketIndex);
- }
- synchronized (mLock) {
- mJobs.forEachJobForSourceUid(uid, job -> {
- // double-check uid vs package name to disambiguate shared uids
- if (packageName.equals(job.getSourcePackageName())) {
- job.setStandbyBucket(bucketIndex);
- }
- });
- onControllerStateChanged();
- }
- });
+ // QuotaController handles this now.
}
@Override
diff --git a/services/core/java/com/android/server/job/controllers/JobStatus.java b/services/core/java/com/android/server/job/controllers/JobStatus.java
index 35fc29ee69fc..6deecbd9a83b 100644
--- a/services/core/java/com/android/server/job/controllers/JobStatus.java
+++ b/services/core/java/com/android/server/job/controllers/JobStatus.java
@@ -77,6 +77,7 @@ public final class JobStatus {
static final int CONSTRAINT_CONNECTIVITY = 1<<28;
static final int CONSTRAINT_CONTENT_TRIGGER = 1<<26;
static final int CONSTRAINT_DEVICE_NOT_DOZING = 1<<25;
+ static final int CONSTRAINT_WITHIN_QUOTA = 1 << 24;
static final int CONSTRAINT_BACKGROUND_NOT_RESTRICTED = 1<<22;
// Soft override: ignore constraints like time that don't affect API availability
@@ -192,6 +193,10 @@ public final class JobStatus {
* Flag for {@link #trackingControllers}: the time controller is currently tracking this job.
*/
public static final int TRACKING_TIME = 1<<5;
+ /**
+ * Flag for {@link #trackingControllers}: the quota controller is currently tracking this job.
+ */
+ public static final int TRACKING_QUOTA = 1 << 6;
/**
* Bit mask of controllers that are currently tracking the job.
@@ -291,6 +296,9 @@ public final class JobStatus {
*/
private boolean mReadyNotRestrictedInBg;
+ /** The job is within its quota based on its standby bucket. */
+ private boolean mReadyWithinQuota;
+
/** Provide a handle to the service that this job will be run on. */
public int getServiceToken() {
return callingUid;
@@ -675,7 +683,6 @@ public final class JobStatus {
return baseHeartbeat;
}
- // Called only by the standby monitoring code
public void setStandbyBucket(int newBucket) {
standbyBucket = newBucket;
}
@@ -876,22 +883,27 @@ public final class JobStatus {
mPersistedUtcTimes = null;
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setChargingConstraintSatisfied(boolean state) {
return setConstraintSatisfied(CONSTRAINT_CHARGING, state);
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setBatteryNotLowConstraintSatisfied(boolean state) {
return setConstraintSatisfied(CONSTRAINT_BATTERY_NOT_LOW, state);
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setStorageNotLowConstraintSatisfied(boolean state) {
return setConstraintSatisfied(CONSTRAINT_STORAGE_NOT_LOW, state);
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setTimingDelayConstraintSatisfied(boolean state) {
return setConstraintSatisfied(CONSTRAINT_TIMING_DELAY, state);
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setDeadlineConstraintSatisfied(boolean state) {
if (setConstraintSatisfied(CONSTRAINT_DEADLINE, state)) {
// The constraint was changed. Update the ready flag.
@@ -901,18 +913,22 @@ public final class JobStatus {
return false;
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setIdleConstraintSatisfied(boolean state) {
return setConstraintSatisfied(CONSTRAINT_IDLE, state);
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setConnectivityConstraintSatisfied(boolean state) {
return setConstraintSatisfied(CONSTRAINT_CONNECTIVITY, state);
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setContentTriggerConstraintSatisfied(boolean state) {
return setConstraintSatisfied(CONSTRAINT_CONTENT_TRIGGER, state);
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setDeviceNotDozingConstraintSatisfied(boolean state, boolean whitelisted) {
dozeWhitelisted = whitelisted;
if (setConstraintSatisfied(CONSTRAINT_DEVICE_NOT_DOZING, state)) {
@@ -923,6 +939,7 @@ public final class JobStatus {
return false;
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setBackgroundNotRestrictedConstraintSatisfied(boolean state) {
if (setConstraintSatisfied(CONSTRAINT_BACKGROUND_NOT_RESTRICTED, state)) {
// The constraint was changed. Update the ready flag.
@@ -932,6 +949,17 @@ public final class JobStatus {
return false;
}
+ /** @return true if the constraint was changed, false otherwise. */
+ boolean setQuotaConstraintSatisfied(boolean state) {
+ if (setConstraintSatisfied(CONSTRAINT_WITHIN_QUOTA, state)) {
+ // The constraint was changed. Update the ready flag.
+ mReadyWithinQuota = state;
+ return true;
+ }
+ return false;
+ }
+
+ /** @return true if the state was changed, false otherwise. */
boolean setUidActive(final boolean newActiveState) {
if (newActiveState != uidActive) {
uidActive = newActiveState;
@@ -940,6 +968,7 @@ public final class JobStatus {
return false; /* unchanged */
}
+ /** @return true if the constraint was changed, false otherwise. */
boolean setConstraintSatisfied(int constraint, boolean state) {
boolean old = (satisfiedConstraints&constraint) != 0;
if (old == state) {
@@ -978,9 +1007,13 @@ public final class JobStatus {
* @return Whether or not this job is ready to run, based on its requirements.
*/
public boolean isReady() {
- // Deadline constraint trumps other constraints (except for periodic jobs where deadline
- // is an implementation detail. A periodic job should only run if its constraints are
- // satisfied).
+ // Quota constraints trumps all other constraints.
+ if (!mReadyWithinQuota) {
+ return false;
+ }
+ // Deadline constraint trumps other constraints besides quota (except for periodic jobs
+ // where deadline is an implementation detail. A periodic job should only run if its
+ // constraints are satisfied).
// DeviceNotDozing implicit constraint must be satisfied
// NotRestrictedInBackground implicit constraint must be satisfied
return mReadyNotDozing && mReadyNotRestrictedInBg && (mReadyDeadlineSatisfied
@@ -1169,6 +1202,9 @@ public final class JobStatus {
if ((constraints&CONSTRAINT_BACKGROUND_NOT_RESTRICTED) != 0) {
pw.print(" BACKGROUND_NOT_RESTRICTED");
}
+ if ((constraints & CONSTRAINT_WITHIN_QUOTA) != 0) {
+ pw.print(" WITHIN_QUOTA");
+ }
if (constraints != 0) {
pw.print(" [0x");
pw.print(Integer.toHexString(constraints));
@@ -1205,6 +1241,9 @@ public final class JobStatus {
if ((constraints & CONSTRAINT_DEVICE_NOT_DOZING) != 0) {
proto.write(fieldId, JobStatusDumpProto.CONSTRAINT_DEVICE_NOT_DOZING);
}
+ if ((constraints & CONSTRAINT_WITHIN_QUOTA) != 0) {
+ proto.write(fieldId, JobStatusDumpProto.CONSTRAINT_WITHIN_QUOTA);
+ }
}
private void dumpJobWorkItem(PrintWriter pw, String prefix, JobWorkItem work, int index) {
@@ -1237,6 +1276,13 @@ public final class JobStatus {
* Returns a bucket name based on the normalized bucket indices, not the AppStandby constants.
*/
String getBucketName() {
+ return bucketName(standbyBucket);
+ }
+
+ /**
+ * Returns a bucket name based on the normalized bucket indices, not the AppStandby constants.
+ */
+ static String bucketName(int standbyBucket) {
switch (standbyBucket) {
case 0: return "ACTIVE";
case 1: return "WORKING_SET";
@@ -1367,7 +1413,8 @@ public final class JobStatus {
dumpConstraints(pw, satisfiedConstraints);
pw.println();
pw.print(prefix); pw.print("Unsatisfied constraints:");
- dumpConstraints(pw, (requiredConstraints & ~satisfiedConstraints));
+ dumpConstraints(pw,
+ ((requiredConstraints | CONSTRAINT_WITHIN_QUOTA) & ~satisfiedConstraints));
pw.println();
if (dozeWhitelisted) {
pw.print(prefix); pw.println("Doze whitelisted: true");
@@ -1375,6 +1422,9 @@ public final class JobStatus {
if (uidActive) {
pw.print(prefix); pw.println("Uid: active");
}
+ if (job.isExemptedFromAppStandby()) {
+ pw.print(prefix); pw.println("Is exempted from app standby");
+ }
}
if (trackingControllers != 0) {
pw.print(prefix); pw.print("Tracking:");
@@ -1384,6 +1434,7 @@ public final class JobStatus {
if ((trackingControllers&TRACKING_IDLE) != 0) pw.print(" IDLE");
if ((trackingControllers&TRACKING_STORAGE) != 0) pw.print(" STORAGE");
if ((trackingControllers&TRACKING_TIME) != 0) pw.print(" TIME");
+ if ((trackingControllers & TRACKING_QUOTA) != 0) pw.print(" QUOTA");
pw.println();
}
@@ -1546,8 +1597,11 @@ public final class JobStatus {
if (full) {
dumpConstraints(proto, JobStatusDumpProto.SATISFIED_CONSTRAINTS, satisfiedConstraints);
dumpConstraints(proto, JobStatusDumpProto.UNSATISFIED_CONSTRAINTS,
- (requiredConstraints & ~satisfiedConstraints));
+ ((requiredConstraints | CONSTRAINT_WITHIN_QUOTA) & ~satisfiedConstraints));
proto.write(JobStatusDumpProto.IS_DOZE_WHITELISTED, dozeWhitelisted);
+ proto.write(JobStatusDumpProto.IS_UID_ACTIVE, uidActive);
+ proto.write(JobStatusDumpProto.IS_EXEMPTED_FROM_APP_STANDBY,
+ job.isExemptedFromAppStandby());
}
// Tracking controllers
@@ -1575,6 +1629,10 @@ public final class JobStatus {
proto.write(JobStatusDumpProto.TRACKING_CONTROLLERS,
JobStatusDumpProto.TRACKING_TIME);
}
+ if ((trackingControllers & TRACKING_QUOTA) != 0) {
+ proto.write(JobStatusDumpProto.TRACKING_CONTROLLERS,
+ JobStatusDumpProto.TRACKING_QUOTA);
+ }
// Implicit constraints
final long icToken = proto.start(JobStatusDumpProto.IMPLICIT_CONSTRAINTS);
diff --git a/services/core/java/com/android/server/job/controllers/QuotaController.java b/services/core/java/com/android/server/job/controllers/QuotaController.java
new file mode 100644
index 000000000000..f73ffac96dfa
--- /dev/null
+++ b/services/core/java/com/android/server/job/controllers/QuotaController.java
@@ -0,0 +1,1299 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.job.controllers;
+
+import static com.android.server.job.JobSchedulerService.ACTIVE_INDEX;
+import static com.android.server.job.JobSchedulerService.FREQUENT_INDEX;
+import static com.android.server.job.JobSchedulerService.NEVER_INDEX;
+import static com.android.server.job.JobSchedulerService.RARE_INDEX;
+import static com.android.server.job.JobSchedulerService.WORKING_INDEX;
+import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.UserIdInt;
+import android.app.AlarmManager;
+import android.app.usage.UsageStatsManagerInternal;
+import android.app.usage.UsageStatsManagerInternal.AppIdleStateChangeListener;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.BatteryManager;
+import android.os.BatteryManagerInternal;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.UserHandle;
+import android.util.ArrayMap;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.Slog;
+import android.util.SparseArray;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.os.BackgroundThread;
+import com.android.internal.util.IndentingPrintWriter;
+import com.android.server.LocalServices;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.StateControllerProto;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+/**
+ * Controller that tracks whether a package has exceeded its standby bucket quota.
+ *
+ * Each job in each bucket is given 10 minutes to run within its respective time window. Active
+ * jobs can run indefinitely, working set jobs can run for 10 minutes within a 2 hour window,
+ * frequent jobs get to run 10 minutes in an 8 hour window, and rare jobs get to run 10 minutes in
+ * a 24 hour window. The windows are rolling, so as soon as a job would have some quota based on its
+ * bucket, it will be eligible to run. When a job's bucket changes, its new quota is immediately
+ * applied to it.
+ *
+ * Test: atest com.android.server.job.controllers.QuotaControllerTest
+ */
+public final class QuotaController extends StateController {
+ private static final String TAG = "JobScheduler.Quota";
+ private static final boolean DEBUG = JobSchedulerService.DEBUG
+ || Log.isLoggable(TAG, Log.DEBUG);
+
+ private static final long MINUTE_IN_MILLIS = 60 * 1000L;
+
+ private static final String ALARM_TAG_CLEANUP = "*job.cleanup*";
+ private static final String ALARM_TAG_QUOTA_CHECK = "*job.quota_check*";
+
+ /**
+ * A sparse array of ArrayMaps, which is suitable for holding (userId, packageName)->object
+ * associations.
+ */
+ private static class UserPackageMap<T> {
+ private final SparseArray<ArrayMap<String, T>> mData = new SparseArray<>();
+
+ public void add(int userId, @NonNull String packageName, @Nullable T obj) {
+ ArrayMap<String, T> data = mData.get(userId);
+ if (data == null) {
+ data = new ArrayMap<String, T>();
+ mData.put(userId, data);
+ }
+ data.put(packageName, obj);
+ }
+
+ @Nullable
+ public T get(int userId, @NonNull String packageName) {
+ ArrayMap<String, T> data = mData.get(userId);
+ if (data != null) {
+ return data.get(packageName);
+ }
+ return null;
+ }
+
+ /** Returns the userId at the given index. */
+ public int keyAt(int index) {
+ return mData.keyAt(index);
+ }
+
+ /** Returns the package name at the given index. */
+ @NonNull
+ public String keyAt(int userIndex, int packageIndex) {
+ return mData.valueAt(userIndex).keyAt(packageIndex);
+ }
+
+ /** Returns the size of the outer (userId) array. */
+ public int numUsers() {
+ return mData.size();
+ }
+
+ public int numPackagesForUser(int userId) {
+ ArrayMap<String, T> data = mData.get(userId);
+ return data == null ? 0 : data.size();
+ }
+
+ /** Returns the value T at the given user and index. */
+ @Nullable
+ public T valueAt(int userIndex, int packageIndex) {
+ return mData.valueAt(userIndex).valueAt(packageIndex);
+ }
+
+ public void forEach(Consumer<T> consumer) {
+ for (int i = numUsers() - 1; i >= 0; --i) {
+ ArrayMap<String, T> data = mData.valueAt(i);
+ for (int j = data.size() - 1; j >= 0; --j) {
+ consumer.accept(data.valueAt(j));
+ }
+ }
+ }
+ }
+
+ /**
+ * Standardize the output of userId-packageName combo.
+ */
+ private static String string(int userId, String packageName) {
+ return "<" + userId + ">" + packageName;
+ }
+
+ @VisibleForTesting
+ static final class Package {
+ public final String packageName;
+ public final int userId;
+
+ Package(int userId, String packageName) {
+ this.userId = userId;
+ this.packageName = packageName;
+ }
+
+ @Override
+ public String toString() {
+ return string(userId, packageName);
+ }
+
+ public void writeToProto(ProtoOutputStream proto, long fieldId) {
+ final long token = proto.start(fieldId);
+
+ proto.write(StateControllerProto.QuotaController.Package.USER_ID, userId);
+ proto.write(StateControllerProto.QuotaController.Package.NAME, packageName);
+
+ proto.end(token);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof Package) {
+ Package other = (Package) obj;
+ return userId == other.userId && Objects.equals(packageName, other.packageName);
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return packageName.hashCode() + userId;
+ }
+ }
+
+ /** List of all tracked jobs keyed by source package-userId combo. */
+ private final UserPackageMap<ArraySet<JobStatus>> mTrackedJobs = new UserPackageMap<>();
+
+ /** Timer for each package-userId combo. */
+ private final UserPackageMap<Timer> mPkgTimers = new UserPackageMap<>();
+
+ /** List of all timing sessions for a package-userId combo, in chronological order. */
+ private final UserPackageMap<List<TimingSession>> mTimingSessions = new UserPackageMap<>();
+
+ /**
+ * List of alarm listeners for each package that listen for when each package comes back within
+ * quota.
+ */
+ private final UserPackageMap<QcAlarmListener> mInQuotaAlarmListeners = new UserPackageMap<>();
+
+ private final AlarmManager mAlarmManager;
+ private final ChargingTracker mChargeTracker;
+ private final Handler mHandler;
+
+ private volatile boolean mInParole;
+
+ /**
+ * If the QuotaController should throttle apps based on their standby bucket and job activity.
+ * If false, all jobs will have their CONSTRAINT_WITHIN_QUOTA bit set to true immediately and
+ * indefinitely.
+ */
+ private boolean mShouldThrottle;
+
+ /** How much time each app will have to run jobs within their standby bucket window. */
+ private long mAllowedTimePerPeriodMs = 10 * MINUTE_IN_MILLIS;
+
+ /**
+ * How much time the package should have before transitioning from out-of-quota to in-quota.
+ * This should not affect processing if the package is already in-quota.
+ */
+ private long mQuotaBufferMs = 30 * 1000L; // 30 seconds
+
+ private long mNextCleanupTimeElapsed = 0;
+ private final AlarmManager.OnAlarmListener mSessionCleanupAlarmListener =
+ new AlarmManager.OnAlarmListener() {
+ @Override
+ public void onAlarm() {
+ mHandler.obtainMessage(MSG_CLEAN_UP_SESSIONS).sendToTarget();
+ }
+ };
+
+ /**
+ * The rolling window size for each standby bucket. Within each window, an app will have 10
+ * minutes to run its jobs.
+ */
+ private final long[] mBucketPeriodsMs = new long[] {
+ 10 * MINUTE_IN_MILLIS, // 10 minutes for ACTIVE -- ACTIVE apps can run jobs at any time
+ 2 * 60 * MINUTE_IN_MILLIS, // 2 hours for WORKING
+ 8 * 60 * MINUTE_IN_MILLIS, // 8 hours for FREQUENT
+ 24 * 60 * MINUTE_IN_MILLIS // 24 hours for RARE
+ };
+
+ /** The maximum period any bucket can have. */
+ private static final long MAX_PERIOD_MS = 24 * 60 * MINUTE_IN_MILLIS;
+
+ /** A package has reached its quota. The message should contain a {@link Package} object. */
+ private static final int MSG_REACHED_QUOTA = 0;
+ /** Drop any old timing sessions. */
+ private static final int MSG_CLEAN_UP_SESSIONS = 1;
+ /** Check if a package is now within its quota. */
+ private static final int MSG_CHECK_PACKAGE = 2;
+
+ public QuotaController(JobSchedulerService service) {
+ super(service);
+ mHandler = new QcHandler(mContext.getMainLooper());
+ mChargeTracker = new ChargingTracker();
+ mChargeTracker.startTracking();
+ mAlarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);
+
+ // Set up the app standby bucketing tracker
+ UsageStatsManagerInternal usageStats = LocalServices.getService(
+ UsageStatsManagerInternal.class);
+ usageStats.addAppIdleStateChangeListener(new StandbyTracker());
+
+ onConstantsUpdatedLocked();
+ }
+
+ @Override
+ public void maybeStartTrackingJobLocked(JobStatus jobStatus, JobStatus lastJob) {
+ // Still need to track jobs even if mShouldThrottle is false in case it's set to true at
+ // some point.
+ ArraySet<JobStatus> jobs = mTrackedJobs.get(jobStatus.getSourceUserId(),
+ jobStatus.getSourcePackageName());
+ if (jobs == null) {
+ jobs = new ArraySet<>();
+ mTrackedJobs.add(jobStatus.getSourceUserId(), jobStatus.getSourcePackageName(), jobs);
+ }
+ jobs.add(jobStatus);
+ jobStatus.setTrackingController(JobStatus.TRACKING_QUOTA);
+ jobStatus.setQuotaConstraintSatisfied(!mShouldThrottle || isWithinQuotaLocked(jobStatus));
+ }
+
+ @Override
+ public void prepareForExecutionLocked(JobStatus jobStatus) {
+ if (DEBUG) Slog.d(TAG, "Prepping for " + jobStatus.toShortString());
+ final int userId = jobStatus.getSourceUserId();
+ final String packageName = jobStatus.getSourcePackageName();
+ Timer timer = mPkgTimers.get(userId, packageName);
+ if (timer == null) {
+ timer = new Timer(userId, packageName);
+ mPkgTimers.add(userId, packageName, timer);
+ }
+ timer.startTrackingJob(jobStatus);
+ }
+
+ @Override
+ public void maybeStopTrackingJobLocked(JobStatus jobStatus, JobStatus incomingJob,
+ boolean forUpdate) {
+ if (jobStatus.clearTrackingController(JobStatus.TRACKING_QUOTA)) {
+ Timer timer = mPkgTimers.get(jobStatus.getSourceUserId(),
+ jobStatus.getSourcePackageName());
+ if (timer != null) {
+ timer.stopTrackingJob(jobStatus);
+ }
+ ArraySet<JobStatus> jobs = mTrackedJobs.get(jobStatus.getSourceUserId(),
+ jobStatus.getSourcePackageName());
+ if (jobs != null) {
+ jobs.remove(jobStatus);
+ }
+ }
+ }
+
+ @Override
+ public void onConstantsUpdatedLocked() {
+ boolean changed = false;
+ if (mShouldThrottle == mConstants.USE_HEARTBEATS) {
+ mShouldThrottle = !mConstants.USE_HEARTBEATS;
+ changed = true;
+ }
+ long newAllowedTimeMs = Math.min(MAX_PERIOD_MS,
+ Math.max(MINUTE_IN_MILLIS, mConstants.QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS));
+ if (mAllowedTimePerPeriodMs != newAllowedTimeMs) {
+ mAllowedTimePerPeriodMs = newAllowedTimeMs;
+ changed = true;
+ }
+ long newQuotaBufferMs = Math.max(0,
+ Math.min(5 * MINUTE_IN_MILLIS, mConstants.QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS));
+ if (mQuotaBufferMs != newQuotaBufferMs) {
+ mQuotaBufferMs = newQuotaBufferMs;
+ changed = true;
+ }
+ long newActivePeriodMs = Math.max(mAllowedTimePerPeriodMs,
+ Math.min(MAX_PERIOD_MS, mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS));
+ if (mBucketPeriodsMs[ACTIVE_INDEX] != newActivePeriodMs) {
+ mBucketPeriodsMs[ACTIVE_INDEX] = newActivePeriodMs;
+ changed = true;
+ }
+ long newWorkingPeriodMs = Math.max(mAllowedTimePerPeriodMs,
+ Math.min(MAX_PERIOD_MS, mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS));
+ if (mBucketPeriodsMs[WORKING_INDEX] != newWorkingPeriodMs) {
+ mBucketPeriodsMs[WORKING_INDEX] = newWorkingPeriodMs;
+ changed = true;
+ }
+ long newFrequentPeriodMs = Math.max(mAllowedTimePerPeriodMs,
+ Math.min(MAX_PERIOD_MS, mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS));
+ if (mBucketPeriodsMs[FREQUENT_INDEX] != newFrequentPeriodMs) {
+ mBucketPeriodsMs[FREQUENT_INDEX] = newFrequentPeriodMs;
+ changed = true;
+ }
+ long newRarePeriodMs = Math.max(mAllowedTimePerPeriodMs,
+ Math.min(MAX_PERIOD_MS, mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS));
+ if (mBucketPeriodsMs[RARE_INDEX] != newRarePeriodMs) {
+ mBucketPeriodsMs[RARE_INDEX] = newRarePeriodMs;
+ changed = true;
+ }
+
+ if (changed) {
+ // Update job bookkeeping out of band.
+ BackgroundThread.getHandler().post(() -> {
+ synchronized (mLock) {
+ maybeUpdateAllConstraintsLocked();
+ }
+ });
+ }
+ }
+
+ /**
+ * Returns an appropriate standby bucket for the job, taking into account any standby
+ * exemptions.
+ */
+ private int getEffectiveStandbyBucket(@NonNull final JobStatus jobStatus) {
+ if (jobStatus.uidActive || jobStatus.getJob().isExemptedFromAppStandby()) {
+ // Treat these cases as if they're in the ACTIVE bucket so that they get throttled
+ // like other ACTIVE apps.
+ return ACTIVE_INDEX;
+ }
+ return jobStatus.getStandbyBucket();
+ }
+
+ private boolean isWithinQuotaLocked(@NonNull final JobStatus jobStatus) {
+ final int standbyBucket = getEffectiveStandbyBucket(jobStatus);
+ return isWithinQuotaLocked(jobStatus.getSourceUserId(), jobStatus.getSourcePackageName(),
+ standbyBucket);
+ }
+
+ private boolean isWithinQuotaLocked(final int userId, @NonNull final String packageName,
+ final int standbyBucket) {
+ if (standbyBucket == NEVER_INDEX) return false;
+ if (standbyBucket == ACTIVE_INDEX) return true;
+ // This check is needed in case the flag is toggled after a job has been registered.
+ if (!mShouldThrottle) return true;
+
+ // Quota constraint is not enforced while charging or when parole is on.
+ return mChargeTracker.isCharging() || mInParole
+ || getRemainingExecutionTimeLocked(userId, packageName, standbyBucket) > 0;
+ }
+
+ @VisibleForTesting
+ long getRemainingExecutionTimeLocked(@NonNull final JobStatus jobStatus) {
+ return getRemainingExecutionTimeLocked(jobStatus.getSourceUserId(),
+ jobStatus.getSourcePackageName(),
+ getEffectiveStandbyBucket(jobStatus));
+ }
+
+ @VisibleForTesting
+ long getRemainingExecutionTimeLocked(final int userId, @NonNull final String packageName) {
+ final int standbyBucket = JobSchedulerService.standbyBucketForPackage(packageName,
+ userId, sElapsedRealtimeClock.millis());
+ return getRemainingExecutionTimeLocked(userId, packageName, standbyBucket);
+ }
+
+ /**
+ * Returns the amount of time, in milliseconds, that this job has remaining to run based on its
+ * current standby bucket. Time remaining could be negative if the app was moved from a less
+ * restricted to a more restricted bucket.
+ */
+ private long getRemainingExecutionTimeLocked(final int userId,
+ @NonNull final String packageName, final int standbyBucket) {
+ if (standbyBucket == NEVER_INDEX) {
+ return 0;
+ }
+ final long bucketWindowSizeMs = mBucketPeriodsMs[standbyBucket];
+ final long trailingRunDurationMs = getTrailingExecutionTimeLocked(
+ userId, packageName, bucketWindowSizeMs);
+ return mAllowedTimePerPeriodMs - trailingRunDurationMs;
+ }
+
+ /** Returns how long the uid has had jobs running within the most recent window. */
+ @VisibleForTesting
+ long getTrailingExecutionTimeLocked(final int userId, @NonNull final String packageName,
+ final long windowSizeMs) {
+ long totalTime = 0;
+
+ Timer timer = mPkgTimers.get(userId, packageName);
+ final long nowElapsed = sElapsedRealtimeClock.millis();
+ if (timer != null && timer.isActive()) {
+ totalTime = timer.getCurrentDuration(nowElapsed);
+ }
+
+ List<TimingSession> sessions = mTimingSessions.get(userId, packageName);
+ if (sessions == null || sessions.size() == 0) {
+ return totalTime;
+ }
+
+ final long startElapsed = nowElapsed - windowSizeMs;
+ // 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) {
+ TimingSession session = sessions.get(i);
+ if (startElapsed < session.startTimeElapsed) {
+ totalTime += session.endTimeElapsed - session.startTimeElapsed;
+ } else if (startElapsed < session.endTimeElapsed) {
+ // The session started before the window but ended within the window. Only include
+ // the portion that was within the window.
+ totalTime += session.endTimeElapsed - startElapsed;
+ } else {
+ // This session ended before the window. No point in going any further.
+ return totalTime;
+ }
+ }
+ return totalTime;
+ }
+
+ @VisibleForTesting
+ void saveTimingSession(final int userId, @NonNull final String packageName,
+ @NonNull final TimingSession session) {
+ synchronized (mLock) {
+ List<TimingSession> sessions = mTimingSessions.get(userId, packageName);
+ if (sessions == null) {
+ sessions = new ArrayList<>();
+ mTimingSessions.add(userId, packageName, sessions);
+ }
+ sessions.add(session);
+
+ maybeScheduleCleanupAlarmLocked();
+ }
+ }
+
+ private final class EarliestEndTimeFunctor implements Consumer<List<TimingSession>> {
+ public long earliestEndElapsed = Long.MAX_VALUE;
+
+ @Override
+ public void accept(List<TimingSession> sessions) {
+ if (sessions != null && sessions.size() > 0) {
+ earliestEndElapsed = Math.min(earliestEndElapsed, sessions.get(0).endTimeElapsed);
+ }
+ }
+
+ void reset() {
+ earliestEndElapsed = Long.MAX_VALUE;
+ }
+ }
+
+ private final EarliestEndTimeFunctor mEarliestEndTimeFunctor = new EarliestEndTimeFunctor();
+
+ /** Schedule a cleanup alarm if necessary and there isn't already one scheduled. */
+ @VisibleForTesting
+ void maybeScheduleCleanupAlarmLocked() {
+ if (mNextCleanupTimeElapsed > sElapsedRealtimeClock.millis()) {
+ // There's already an alarm scheduled. Just stick with that one. There's no way we'll
+ // end up scheduling an earlier alarm.
+ if (DEBUG) {
+ Slog.v(TAG, "Not scheduling cleanup since there's already one at "
+ + mNextCleanupTimeElapsed + " (in " + (mNextCleanupTimeElapsed
+ - sElapsedRealtimeClock.millis()) + "ms)");
+ }
+ return;
+ }
+ mEarliestEndTimeFunctor.reset();
+ mTimingSessions.forEach(mEarliestEndTimeFunctor);
+ final long earliestEndElapsed = mEarliestEndTimeFunctor.earliestEndElapsed;
+ if (earliestEndElapsed == Long.MAX_VALUE) {
+ // Couldn't find a good time to clean up. Maybe this was called after we deleted all
+ // timing sessions.
+ if (DEBUG) Slog.d(TAG, "Didn't find a time to schedule cleanup");
+ return;
+ }
+ // Need to keep sessions for all apps up to the max period, regardless of their current
+ // standby bucket.
+ long nextCleanupElapsed = earliestEndElapsed + MAX_PERIOD_MS;
+ if (nextCleanupElapsed - mNextCleanupTimeElapsed <= 10 * MINUTE_IN_MILLIS) {
+ // No need to clean up too often. Delay the alarm if the next cleanup would be too soon
+ // after it.
+ nextCleanupElapsed += 10 * MINUTE_IN_MILLIS;
+ }
+ mNextCleanupTimeElapsed = nextCleanupElapsed;
+ mAlarmManager.set(AlarmManager.ELAPSED_REALTIME, nextCleanupElapsed, ALARM_TAG_CLEANUP,
+ mSessionCleanupAlarmListener, mHandler);
+ if (DEBUG) Slog.d(TAG, "Scheduled next cleanup for " + mNextCleanupTimeElapsed);
+ }
+
+ private void handleNewChargingStateLocked() {
+ final long nowElapsed = sElapsedRealtimeClock.millis();
+ final boolean isCharging = mChargeTracker.isCharging();
+ if (DEBUG) Slog.d(TAG, "handleNewChargingStateLocked: " + isCharging);
+ // Deal with Timers first.
+ mPkgTimers.forEach((t) -> t.onChargingChanged(nowElapsed, isCharging));
+ // Now update jobs.
+ maybeUpdateAllConstraintsLocked();
+ }
+
+ private void maybeUpdateAllConstraintsLocked() {
+ boolean changed = false;
+ for (int u = 0; u < mTrackedJobs.numUsers(); ++u) {
+ final int userId = mTrackedJobs.keyAt(u);
+ for (int p = 0; p < mTrackedJobs.numPackagesForUser(userId); ++p) {
+ final String packageName = mTrackedJobs.keyAt(u, p);
+ changed |= maybeUpdateConstraintForPkgLocked(userId, packageName);
+ }
+ }
+ if (changed) {
+ mStateChangedListener.onControllerStateChanged();
+ }
+ }
+
+ /**
+ * Update the CONSTRAINT_WITHIN_QUOTA bit for all of the Jobs for a given package.
+ *
+ * @return true if at least one job had its bit changed
+ */
+ private boolean maybeUpdateConstraintForPkgLocked(final int userId,
+ @NonNull final String packageName) {
+ ArraySet<JobStatus> jobs = mTrackedJobs.get(userId, packageName);
+ if (jobs == null || jobs.size() == 0) {
+ return false;
+ }
+
+ // Quota is the same for all jobs within a package.
+ final int realStandbyBucket = jobs.valueAt(0).getStandbyBucket();
+ final boolean realInQuota = isWithinQuotaLocked(userId, packageName, realStandbyBucket);
+ boolean changed = false;
+ for (int i = jobs.size() - 1; i >= 0; --i) {
+ final JobStatus js = jobs.valueAt(i);
+ if (realStandbyBucket == getEffectiveStandbyBucket(js)) {
+ changed |= js.setQuotaConstraintSatisfied(realInQuota);
+ } else {
+ // This job is somehow exempted. Need to determine its own quota status.
+ changed |= js.setQuotaConstraintSatisfied(isWithinQuotaLocked(js));
+ }
+ }
+ if (!realInQuota) {
+ // Don't want to use the effective standby bucket here since that bump the bucket to
+ // ACTIVE for one of the jobs, which doesn't help with other jobs that aren't
+ // exempted.
+ maybeScheduleStartAlarmLocked(userId, packageName, realStandbyBucket);
+ } else {
+ QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, packageName);
+ if (alarmListener != null) {
+ mAlarmManager.cancel(alarmListener);
+ // Set the trigger time to 0 so that the alarm doesn't think it's still waiting.
+ alarmListener.setTriggerTime(0);
+ }
+ }
+ return changed;
+ }
+
+ /**
+ * Maybe schedule a non-wakeup alarm for the next time this package will have quota to run
+ * again. This should only be called if the package is already out of quota.
+ */
+ @VisibleForTesting
+ void maybeScheduleStartAlarmLocked(final int userId, @NonNull final String packageName,
+ final int standbyBucket) {
+ final String pkgString = string(userId, packageName);
+ if (standbyBucket == NEVER_INDEX) {
+ return;
+ } else if (standbyBucket == ACTIVE_INDEX) {
+ // ACTIVE apps are "always" in quota.
+ if (DEBUG) {
+ Slog.w(TAG, "maybeScheduleStartAlarmLocked called for " + pkgString
+ + " even though it is active");
+ }
+ mHandler.obtainMessage(MSG_CHECK_PACKAGE, userId, 0, packageName).sendToTarget();
+
+ QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, packageName);
+ if (alarmListener != null) {
+ // Cancel any pending alarm.
+ mAlarmManager.cancel(alarmListener);
+ // Set the trigger time to 0 so that the alarm doesn't think it's still waiting.
+ alarmListener.setTriggerTime(0);
+ }
+ return;
+ }
+
+ List<TimingSession> sessions = mTimingSessions.get(userId, packageName);
+ if (sessions == null || sessions.size() == 0) {
+ // If there are no sessions, then the job is probably in quota.
+ if (DEBUG) {
+ Slog.wtf(TAG, "maybeScheduleStartAlarmLocked called for " + pkgString
+ + " even though it is likely within its quota.");
+ }
+ mHandler.obtainMessage(MSG_CHECK_PACKAGE, userId, 0, packageName).sendToTarget();
+ return;
+ }
+
+ final long bucketWindowSizeMs = mBucketPeriodsMs[standbyBucket];
+ final long nowElapsed = sElapsedRealtimeClock.millis();
+ // How far back we need to look.
+ final long startElapsed = nowElapsed - bucketWindowSizeMs;
+
+ long totalTime = 0;
+ long cutoffTimeElapsed = nowElapsed;
+ for (int i = sessions.size() - 1; i >= 0; i--) {
+ TimingSession session = sessions.get(i);
+ if (startElapsed < session.startTimeElapsed) {
+ cutoffTimeElapsed = session.startTimeElapsed;
+ totalTime += session.endTimeElapsed - session.startTimeElapsed;
+ } else if (startElapsed < session.endTimeElapsed) {
+ // The session started before the window but ended within the window. Only
+ // include the portion that was within the window.
+ cutoffTimeElapsed = startElapsed;
+ totalTime += session.endTimeElapsed - startElapsed;
+ } else {
+ // This session ended before the window. No point in going any further.
+ break;
+ }
+ if (totalTime >= mAllowedTimePerPeriodMs) {
+ break;
+ }
+ }
+ if (totalTime < mAllowedTimePerPeriodMs) {
+ // Already in quota. Why was this method called?
+ if (DEBUG) {
+ Slog.w(TAG, "maybeScheduleStartAlarmLocked called for " + pkgString
+ + " even though it already has " + (mAllowedTimePerPeriodMs - totalTime)
+ + "ms in its quota.");
+ }
+ mHandler.obtainMessage(MSG_CHECK_PACKAGE, userId, 0, packageName).sendToTarget();
+ return;
+ }
+
+ QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, packageName);
+ if (alarmListener == null) {
+ alarmListener = new QcAlarmListener(userId, packageName);
+ mInQuotaAlarmListeners.add(userId, packageName, alarmListener);
+ }
+
+ // We add all the way back to the beginning of a session (or the window) even when we don't
+ // need to (in order to simplify the for loop above), so there might be some extra we
+ // need to add back.
+ final long extraTimeMs = totalTime - mAllowedTimePerPeriodMs;
+ // The time this app will have quota again.
+ final long inQuotaTimeElapsed =
+ cutoffTimeElapsed + extraTimeMs + mQuotaBufferMs + bucketWindowSizeMs;
+ // Only schedule the alarm if:
+ // 1. There isn't one currently scheduled
+ // 2. The new alarm is significantly earlier than the previous alarm (which could be the
+ // case if the package moves into a higher standby bucket). If it's earlier but not
+ // significantly so, then we essentially delay the job a few extra minutes.
+ // 3. The alarm is after the current alarm by more than the quota buffer.
+ // TODO: this might be overengineering. Simplify if proven safe.
+ if (!alarmListener.isWaiting()
+ || inQuotaTimeElapsed < alarmListener.getTriggerTimeElapsed() - 3 * MINUTE_IN_MILLIS
+ || alarmListener.getTriggerTimeElapsed() < inQuotaTimeElapsed - mQuotaBufferMs) {
+ if (DEBUG) Slog.d(TAG, "Scheduling start alarm for " + pkgString);
+ // If the next time this app will have quota is at least 3 minutes before the
+ // alarm is supposed to go off, reschedule the alarm.
+ mAlarmManager.set(AlarmManager.ELAPSED_REALTIME, inQuotaTimeElapsed,
+ ALARM_TAG_QUOTA_CHECK, alarmListener, mHandler);
+ alarmListener.setTriggerTime(inQuotaTimeElapsed);
+ }
+ }
+
+ private final class ChargingTracker extends BroadcastReceiver {
+ /**
+ * Track whether we're charging. This has a slightly different definition than that of
+ * BatteryController.
+ */
+ private boolean mCharging;
+
+ ChargingTracker() {
+ }
+
+ public void startTracking() {
+ IntentFilter filter = new IntentFilter();
+
+ // Charging/not charging.
+ filter.addAction(BatteryManager.ACTION_CHARGING);
+ filter.addAction(BatteryManager.ACTION_DISCHARGING);
+ mContext.registerReceiver(this, filter);
+
+ // Initialise tracker state.
+ BatteryManagerInternal batteryManagerInternal =
+ LocalServices.getService(BatteryManagerInternal.class);
+ mCharging = batteryManagerInternal.isPowered(BatteryManager.BATTERY_PLUGGED_ANY);
+ }
+
+ public boolean isCharging() {
+ return mCharging;
+ }
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ synchronized (mLock) {
+ final String action = intent.getAction();
+ if (BatteryManager.ACTION_CHARGING.equals(action)) {
+ if (DEBUG) {
+ Slog.d(TAG, "Received charging intent, fired @ "
+ + sElapsedRealtimeClock.millis());
+ }
+ mCharging = true;
+ handleNewChargingStateLocked();
+ } else if (BatteryManager.ACTION_DISCHARGING.equals(action)) {
+ if (DEBUG) {
+ Slog.d(TAG, "Disconnected from power.");
+ }
+ mCharging = false;
+ handleNewChargingStateLocked();
+ }
+ }
+ }
+ }
+
+ @VisibleForTesting
+ static final class TimingSession {
+ // Start timestamp in elapsed realtime timebase.
+ public final long startTimeElapsed;
+ // End timestamp in elapsed realtime timebase.
+ public final long endTimeElapsed;
+ // How many jobs ran during this session.
+ public final int jobCount;
+
+ TimingSession(long startElapsed, long endElapsed, int jobCount) {
+ this.startTimeElapsed = startElapsed;
+ this.endTimeElapsed = endElapsed;
+ this.jobCount = jobCount;
+ }
+
+ @Override
+ public String toString() {
+ return "TimingSession{" + startTimeElapsed + "->" + endTimeElapsed + ", " + jobCount
+ + "}";
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof TimingSession) {
+ TimingSession other = (TimingSession) obj;
+ return startTimeElapsed == other.startTimeElapsed
+ && endTimeElapsed == other.endTimeElapsed
+ && jobCount == other.jobCount;
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new long[] {startTimeElapsed, endTimeElapsed, jobCount});
+ }
+
+ public void dump(IndentingPrintWriter pw) {
+ pw.print(startTimeElapsed);
+ pw.print(" -> ");
+ pw.print(endTimeElapsed);
+ pw.print(" (");
+ pw.print(endTimeElapsed - startTimeElapsed);
+ pw.print("), ");
+ pw.print(jobCount);
+ pw.print(" jobs.");
+ pw.println();
+ }
+
+ public void dump(@NonNull ProtoOutputStream proto, long fieldId) {
+ final long token = proto.start(fieldId);
+
+ proto.write(StateControllerProto.QuotaController.TimingSession.START_TIME_ELAPSED,
+ startTimeElapsed);
+ proto.write(StateControllerProto.QuotaController.TimingSession.END_TIME_ELAPSED,
+ endTimeElapsed);
+ proto.write(StateControllerProto.QuotaController.TimingSession.JOB_COUNT, jobCount);
+
+ proto.end(token);
+ }
+ }
+
+ private final class Timer {
+ private final Package mPkg;
+
+ // List of jobs currently running for this package.
+ private final ArraySet<JobStatus> mRunningJobs = new ArraySet<>();
+ private long mStartTimeElapsed;
+ private int mJobCount;
+
+ Timer(int userId, String packageName) {
+ mPkg = new Package(userId, packageName);
+ }
+
+ void startTrackingJob(@NonNull JobStatus jobStatus) {
+ if (DEBUG) Slog.v(TAG, "Starting to track " + jobStatus.toShortString());
+ synchronized (mLock) {
+ // Always track jobs, even when charging.
+ mRunningJobs.add(jobStatus);
+ if (!mChargeTracker.isCharging()) {
+ mJobCount++;
+ if (mRunningJobs.size() == 1) {
+ // Started tracking the first job.
+ mStartTimeElapsed = sElapsedRealtimeClock.millis();
+ scheduleCutoff();
+ }
+ }
+ }
+ }
+
+ void stopTrackingJob(@NonNull JobStatus jobStatus) {
+ if (DEBUG) Slog.v(TAG, "Stopping tracking of " + jobStatus.toShortString());
+ synchronized (mLock) {
+ if (mRunningJobs.size() == 0) {
+ // maybeStopTrackingJobLocked can be called when an app cancels a job, so a
+ // timer may not be running when it's asked to stop tracking a job.
+ if (DEBUG) {
+ Slog.d(TAG, "Timer isn't tracking any jobs but still told to stop");
+ }
+ return;
+ }
+ mRunningJobs.remove(jobStatus);
+ if (!mChargeTracker.isCharging() && mRunningJobs.size() == 0) {
+ emitSessionLocked(sElapsedRealtimeClock.millis());
+ cancelCutoff();
+ }
+ }
+ }
+
+ private void emitSessionLocked(long nowElapsed) {
+ if (mJobCount <= 0) {
+ // Nothing to emit.
+ return;
+ }
+ TimingSession ts = new TimingSession(mStartTimeElapsed, nowElapsed, mJobCount);
+ saveTimingSession(mPkg.userId, mPkg.packageName, ts);
+ mJobCount = 0;
+ // Don't reset the tracked jobs list as we need to keep tracking the current number
+ // of jobs.
+ // However, cancel the currently scheduled cutoff since it's not currently useful.
+ cancelCutoff();
+ }
+
+ /**
+ * Returns true if the Timer is actively tracking, as opposed to passively ref counting
+ * during charging.
+ */
+ public boolean isActive() {
+ synchronized (mLock) {
+ return mJobCount > 0;
+ }
+ }
+
+ long getCurrentDuration(long nowElapsed) {
+ synchronized (mLock) {
+ return !isActive() ? 0 : nowElapsed - mStartTimeElapsed;
+ }
+ }
+
+ void onChargingChanged(long nowElapsed, boolean isCharging) {
+ synchronized (mLock) {
+ if (isCharging) {
+ emitSessionLocked(nowElapsed);
+ } else {
+ // Start timing from unplug.
+ if (mRunningJobs.size() > 0) {
+ mStartTimeElapsed = nowElapsed;
+ // NOTE: this does have the unfortunate consequence that if the device is
+ // repeatedly plugged in and unplugged, the job count for a package may be
+ // artificially high.
+ mJobCount = mRunningJobs.size();
+ // Schedule cutoff since we're now actively tracking for quotas again.
+ scheduleCutoff();
+ }
+ }
+ }
+ }
+
+ void rescheduleCutoff() {
+ cancelCutoff();
+ scheduleCutoff();
+ }
+
+ private void scheduleCutoff() {
+ // Each package can only be in one standby bucket, so we only need to have one
+ // message per timer. We only need to reschedule when restarting timer or when
+ // standby bucket changes.
+ synchronized (mLock) {
+ if (!isActive()) {
+ return;
+ }
+ Message msg = mHandler.obtainMessage(MSG_REACHED_QUOTA, mPkg);
+ final long timeRemainingMs = getRemainingExecutionTimeLocked(mPkg.userId,
+ mPkg.packageName);
+ if (DEBUG) {
+ Slog.i(TAG, "Job for " + mPkg + " has " + timeRemainingMs + "ms left.");
+ }
+ // If the job was running the entire time, then the system would be up, so it's
+ // fine to use uptime millis for these messages.
+ mHandler.sendMessageDelayed(msg, timeRemainingMs);
+ }
+ }
+
+ private void cancelCutoff() {
+ mHandler.removeMessages(MSG_REACHED_QUOTA, mPkg);
+ }
+
+ public void dump(IndentingPrintWriter pw, Predicate<JobStatus> predicate) {
+ pw.print("Timer{");
+ pw.print(mPkg);
+ pw.print("} ");
+ if (isActive()) {
+ pw.print("started at ");
+ pw.print(mStartTimeElapsed);
+ } else {
+ pw.print("NOT active");
+ }
+ pw.print(", ");
+ pw.print(mJobCount);
+ pw.print(" running jobs");
+ pw.println();
+ pw.increaseIndent();
+ for (int i = 0; i < mRunningJobs.size(); i++) {
+ JobStatus js = mRunningJobs.valueAt(i);
+ if (predicate.test(js)) {
+ pw.println(js.toShortString());
+ }
+ }
+
+ pw.decreaseIndent();
+ }
+
+ public void dump(ProtoOutputStream proto, long fieldId, Predicate<JobStatus> predicate) {
+ final long token = proto.start(fieldId);
+
+ mPkg.writeToProto(proto, StateControllerProto.QuotaController.Timer.PKG);
+ proto.write(StateControllerProto.QuotaController.Timer.IS_ACTIVE, isActive());
+ proto.write(StateControllerProto.QuotaController.Timer.START_TIME_ELAPSED,
+ mStartTimeElapsed);
+ proto.write(StateControllerProto.QuotaController.Timer.JOB_COUNT, mJobCount);
+ for (int i = 0; i < mRunningJobs.size(); i++) {
+ JobStatus js = mRunningJobs.valueAt(i);
+ if (predicate.test(js)) {
+ js.writeToShortProto(proto,
+ StateControllerProto.QuotaController.Timer.RUNNING_JOBS);
+ }
+ }
+
+ proto.end(token);
+ }
+ }
+
+ /**
+ * Tracking of app assignments to standby buckets
+ */
+ final class StandbyTracker extends AppIdleStateChangeListener {
+
+ @Override
+ public void onAppIdleStateChanged(final String packageName, final @UserIdInt int userId,
+ boolean idle, int bucket, int reason) {
+ // Update job bookkeeping out of band.
+ BackgroundThread.getHandler().post(() -> {
+ final int bucketIndex = JobSchedulerService.standbyBucketToBucketIndex(bucket);
+ if (DEBUG) {
+ Slog.i(TAG, "Moving pkg " + string(userId, packageName) + " to bucketIndex "
+ + bucketIndex);
+ }
+ synchronized (mLock) {
+ ArraySet<JobStatus> jobs = mTrackedJobs.get(userId, packageName);
+ if (jobs == null || jobs.size() == 0) {
+ return;
+ }
+ for (int i = jobs.size() - 1; i >= 0; i--) {
+ JobStatus js = jobs.valueAt(i);
+ js.setStandbyBucket(bucketIndex);
+ }
+ Timer timer = mPkgTimers.get(userId, packageName);
+ if (timer != null && timer.isActive()) {
+ timer.rescheduleCutoff();
+ }
+ if (!mShouldThrottle || maybeUpdateConstraintForPkgLocked(userId,
+ packageName)) {
+ mStateChangedListener.onControllerStateChanged();
+ }
+ }
+ });
+ }
+
+ @Override
+ public void onParoleStateChanged(final boolean isParoleOn) {
+ mInParole = isParoleOn;
+ if (DEBUG) Slog.i(TAG, "Global parole state now " + (isParoleOn ? "ON" : "OFF"));
+ // Update job bookkeeping out of band.
+ BackgroundThread.getHandler().post(() -> {
+ synchronized (mLock) {
+ maybeUpdateAllConstraintsLocked();
+ }
+ });
+ }
+ }
+
+ private final class DeleteTimingSessionsFunctor implements Consumer<List<TimingSession>> {
+ private final Predicate<TimingSession> mTooOld = new Predicate<TimingSession>() {
+ public boolean test(TimingSession ts) {
+ return ts.endTimeElapsed <= sElapsedRealtimeClock.millis() - MAX_PERIOD_MS;
+ }
+ };
+
+ @Override
+ public void accept(List<TimingSession> sessions) {
+ if (sessions != null) {
+ // Remove everything older than MAX_PERIOD_MS time ago.
+ sessions.removeIf(mTooOld);
+ }
+ }
+ }
+
+ private final DeleteTimingSessionsFunctor mDeleteOldSessionsFunctor =
+ new DeleteTimingSessionsFunctor();
+
+ @VisibleForTesting
+ void deleteObsoleteSessionsLocked() {
+ mTimingSessions.forEach(mDeleteOldSessionsFunctor);
+ }
+
+ private class QcHandler extends Handler {
+ QcHandler(Looper looper) {
+ super(looper);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ synchronized (mLock) {
+ switch (msg.what) {
+ case MSG_REACHED_QUOTA: {
+ Package pkg = (Package) msg.obj;
+ if (DEBUG) Slog.d(TAG, "Checking if " + pkg + " has reached its quota.");
+
+ long timeRemainingMs = getRemainingExecutionTimeLocked(pkg.userId,
+ pkg.packageName);
+ if (timeRemainingMs <= 50) {
+ // Less than 50 milliseconds left. Start process of shutting down jobs.
+ if (DEBUG) Slog.d(TAG, pkg + " has reached its quota.");
+ if (maybeUpdateConstraintForPkgLocked(pkg.userId, pkg.packageName)) {
+ mStateChangedListener.onControllerStateChanged();
+ }
+ } else {
+ // This could potentially happen if an old session phases out while a
+ // job is currently running.
+ // Reschedule message
+ Message rescheduleMsg = obtainMessage(MSG_REACHED_QUOTA, pkg);
+ if (DEBUG) {
+ Slog.d(TAG, pkg + " has " + timeRemainingMs + "ms left.");
+ }
+ sendMessageDelayed(rescheduleMsg, timeRemainingMs);
+ }
+ break;
+ }
+ case MSG_CLEAN_UP_SESSIONS:
+ if (DEBUG) Slog.d(TAG, "Cleaning up timing sessions.");
+ deleteObsoleteSessionsLocked();
+ maybeScheduleCleanupAlarmLocked();
+
+ break;
+ case MSG_CHECK_PACKAGE: {
+ String packageName = (String) msg.obj;
+ int userId = msg.arg1;
+ if (DEBUG) Slog.d(TAG, "Checking pkg " + string(userId, packageName));
+ if (maybeUpdateConstraintForPkgLocked(userId, packageName)) {
+ mStateChangedListener.onControllerStateChanged();
+ }
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ private class QcAlarmListener implements AlarmManager.OnAlarmListener {
+ private final int mUserId;
+ private final String mPackageName;
+ private volatile long mTriggerTimeElapsed;
+
+ QcAlarmListener(int userId, String packageName) {
+ mUserId = userId;
+ mPackageName = packageName;
+ }
+
+ boolean isWaiting() {
+ return mTriggerTimeElapsed > 0;
+ }
+
+ void setTriggerTime(long timeElapsed) {
+ mTriggerTimeElapsed = timeElapsed;
+ }
+
+ long getTriggerTimeElapsed() {
+ return mTriggerTimeElapsed;
+ }
+
+ @Override
+ public void onAlarm() {
+ mHandler.obtainMessage(MSG_CHECK_PACKAGE, mUserId, 0, mPackageName).sendToTarget();
+ mTriggerTimeElapsed = 0;
+ }
+ }
+
+ //////////////////////// TESTING HELPERS /////////////////////////////
+
+ @VisibleForTesting
+ long getAllowedTimePerPeriodMs() {
+ return mAllowedTimePerPeriodMs;
+ }
+
+ @VisibleForTesting
+ @NonNull
+ long[] getBucketWindowSizes() {
+ return mBucketPeriodsMs;
+ }
+
+ @VisibleForTesting
+ @NonNull
+ Handler getHandler() {
+ return mHandler;
+ }
+
+ @VisibleForTesting
+ long getInQuotaBufferMs() {
+ return mQuotaBufferMs;
+ }
+
+ @VisibleForTesting
+ @Nullable
+ List<TimingSession> getTimingSessions(int userId, String packageName) {
+ return mTimingSessions.get(userId, packageName);
+ }
+
+ //////////////////////////// DATA DUMP //////////////////////////////
+
+ @Override
+ public void dumpControllerStateLocked(final IndentingPrintWriter pw,
+ final Predicate<JobStatus> predicate) {
+ pw.println("Is throttling: " + mShouldThrottle);
+ pw.println("Is charging: " + mChargeTracker.isCharging());
+ pw.println("In parole: " + mInParole);
+ pw.println();
+
+ mTrackedJobs.forEach((jobs) -> {
+ for (int j = 0; j < jobs.size(); j++) {
+ final JobStatus js = jobs.valueAt(j);
+ if (!predicate.test(js)) {
+ continue;
+ }
+ pw.print("#");
+ js.printUniqueId(pw);
+ pw.print(" from ");
+ UserHandle.formatUid(pw, js.getSourceUid());
+ pw.println();
+
+ pw.increaseIndent();
+ pw.print(JobStatus.bucketName(getEffectiveStandbyBucket(js)));
+ pw.print(", ");
+ if (js.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA)) {
+ pw.print("within quota");
+ } else {
+ pw.print("not within quota");
+ }
+ pw.print(", ");
+ pw.print(getRemainingExecutionTimeLocked(js));
+ pw.print("ms remaining in quota");
+ pw.decreaseIndent();
+ pw.println();
+ }
+ });
+
+ pw.println();
+ for (int u = 0; u < mPkgTimers.numUsers(); ++u) {
+ final int userId = mPkgTimers.keyAt(u);
+ for (int p = 0; p < mPkgTimers.numPackagesForUser(userId); ++p) {
+ final String pkgName = mPkgTimers.keyAt(u, p);
+ mPkgTimers.valueAt(u, p).dump(pw, predicate);
+ pw.println();
+ List<TimingSession> sessions = mTimingSessions.get(userId, pkgName);
+ if (sessions != null) {
+ pw.increaseIndent();
+ pw.println("Saved sessions:");
+ pw.increaseIndent();
+ for (int j = sessions.size() - 1; j >= 0; j--) {
+ TimingSession session = sessions.get(j);
+ session.dump(pw);
+ }
+ pw.decreaseIndent();
+ pw.decreaseIndent();
+ pw.println();
+ }
+ }
+ }
+ }
+
+ @Override
+ public void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
+ Predicate<JobStatus> predicate) {
+ final long token = proto.start(fieldId);
+ final long mToken = proto.start(StateControllerProto.QUOTA);
+
+ proto.write(StateControllerProto.QuotaController.IS_CHARGING, mChargeTracker.isCharging());
+ proto.write(StateControllerProto.QuotaController.IS_IN_PAROLE, mInParole);
+
+ mTrackedJobs.forEach((jobs) -> {
+ for (int j = 0; j < jobs.size(); j++) {
+ final JobStatus js = jobs.valueAt(j);
+ if (!predicate.test(js)) {
+ continue;
+ }
+ final long jsToken = proto.start(
+ StateControllerProto.QuotaController.TRACKED_JOBS);
+ js.writeToShortProto(proto,
+ StateControllerProto.QuotaController.TrackedJob.INFO);
+ proto.write(StateControllerProto.QuotaController.TrackedJob.SOURCE_UID,
+ js.getSourceUid());
+ proto.write(
+ StateControllerProto.QuotaController.TrackedJob.EFFECTIVE_STANDBY_BUCKET,
+ getEffectiveStandbyBucket(js));
+ proto.write(StateControllerProto.QuotaController.TrackedJob.HAS_QUOTA,
+ js.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
+ proto.write(StateControllerProto.QuotaController.TrackedJob.REMAINING_QUOTA_MS,
+ getRemainingExecutionTimeLocked(js));
+ proto.end(jsToken);
+ }
+ });
+
+ for (int u = 0; u < mPkgTimers.numUsers(); ++u) {
+ final int userId = mPkgTimers.keyAt(u);
+ for (int p = 0; p < mPkgTimers.numPackagesForUser(userId); ++p) {
+ final String pkgName = mPkgTimers.keyAt(u, p);
+ final long psToken = proto.start(
+ StateControllerProto.QuotaController.PACKAGE_STATS);
+ mPkgTimers.valueAt(u, p).dump(proto,
+ StateControllerProto.QuotaController.PackageStats.TIMER, predicate);
+
+ List<TimingSession> sessions = mTimingSessions.get(userId, pkgName);
+ if (sessions != null) {
+ for (int j = sessions.size() - 1; j >= 0; j--) {
+ TimingSession session = sessions.get(j);
+ session.dump(proto,
+ StateControllerProto.QuotaController.PackageStats.SAVED_SESSIONS);
+ }
+ }
+
+ proto.end(psToken);
+ }
+ }
+
+ proto.end(mToken);
+ proto.end(token);
+ }
+}
diff --git a/services/core/java/com/android/server/job/controllers/StateController.java b/services/core/java/com/android/server/job/controllers/StateController.java
index c2be28336406..b439c0ddd028 100644
--- a/services/core/java/com/android/server/job/controllers/StateController.java
+++ b/services/core/java/com/android/server/job/controllers/StateController.java
@@ -53,22 +53,31 @@ public abstract class StateController {
* preexisting tasks.
*/
public abstract void maybeStartTrackingJobLocked(JobStatus jobStatus, JobStatus lastJob);
+
/**
* Optionally implement logic here to prepare the job to be executed.
*/
public void prepareForExecutionLocked(JobStatus jobStatus) {
}
+
/**
* Remove task - this will happen if the task is cancelled, completed, etc.
*/
public abstract void maybeStopTrackingJobLocked(JobStatus jobStatus, JobStatus incomingJob,
boolean forUpdate);
+
/**
* Called when a new job is being created to reschedule an old failed job.
*/
public void rescheduleForFailureLocked(JobStatus newJob, JobStatus failureToReschedule) {
}
+ /**
+ * Called when the JobScheduler.Constants are updated.
+ */
+ public void onConstantsUpdatedLocked() {
+ }
+
public abstract void dumpControllerStateLocked(IndentingPrintWriter pw,
Predicate<JobStatus> predicate);
public abstract void dumpControllerStateLocked(ProtoOutputStream proto, long fieldId,
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
new file mode 100644
index 000000000000..b2ec83583eba
--- /dev/null
+++ b/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java
@@ -0,0 +1,842 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.job.controllers;
+
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doNothing;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.inOrder;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.mock;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.spyOn;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.when;
+import static com.android.server.job.JobSchedulerService.ACTIVE_INDEX;
+import static com.android.server.job.JobSchedulerService.FREQUENT_INDEX;
+import static com.android.server.job.JobSchedulerService.RARE_INDEX;
+import static com.android.server.job.JobSchedulerService.WORKING_INDEX;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.app.AlarmManager;
+import android.app.job.JobInfo;
+import android.app.usage.UsageStatsManager;
+import android.app.usage.UsageStatsManagerInternal;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManagerInternal;
+import android.os.BatteryManager;
+import android.os.BatteryManagerInternal;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+
+import androidx.test.runner.AndroidJUnit4;
+
+import com.android.server.LocalServices;
+import com.android.server.job.JobSchedulerService;
+import com.android.server.job.JobSchedulerService.Constants;
+import com.android.server.job.controllers.QuotaController.TimingSession;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.MockitoSession;
+import org.mockito.quality.Strictness;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class QuotaControllerTest {
+ private static final long SECOND_IN_MILLIS = 1000L;
+ private static final long MINUTE_IN_MILLIS = 60 * SECOND_IN_MILLIS;
+ private static final long HOUR_IN_MILLIS = 60 * MINUTE_IN_MILLIS;
+ private static final String TAG_CLEANUP = "*job.cleanup*";
+ private static final String TAG_QUOTA_CHECK = "*job.quota_check*";
+ private static final long IN_QUOTA_BUFFER_MILLIS = 30 * SECOND_IN_MILLIS;
+ private static final int CALLING_UID = 1000;
+ private static final String SOURCE_PACKAGE = "com.android.frameworks.mockingservicestests";
+ private static final int SOURCE_USER_ID = 0;
+
+ private BroadcastReceiver mChargingReceiver;
+ private Constants mConstants;
+ private QuotaController mQuotaController;
+
+ private MockitoSession mMockingSession;
+ @Mock
+ private AlarmManager mAlarmManager;
+ @Mock
+ private Context mContext;
+ @Mock
+ private JobSchedulerService mJobSchedulerService;
+ @Mock
+ private UsageStatsManagerInternal mUsageStatsManager;
+
+ @Before
+ public void setUp() {
+ mMockingSession = mockitoSession()
+ .initMocks(this)
+ .strictness(Strictness.LENIENT)
+ .mockStatic(LocalServices.class)
+ .startMocking();
+ // Make sure constants turn on QuotaController.
+ mConstants = new Constants();
+ mConstants.USE_HEARTBEATS = false;
+
+ // Called in StateController constructor.
+ when(mJobSchedulerService.getTestableContext()).thenReturn(mContext);
+ when(mJobSchedulerService.getLock()).thenReturn(mJobSchedulerService);
+ when(mJobSchedulerService.getConstants()).thenReturn(mConstants);
+ // Called in QuotaController constructor.
+ when(mContext.getMainLooper()).thenReturn(Looper.getMainLooper());
+ when(mContext.getSystemService(Context.ALARM_SERVICE)).thenReturn(mAlarmManager);
+ doReturn(mock(BatteryManagerInternal.class))
+ .when(() -> LocalServices.getService(BatteryManagerInternal.class));
+ doReturn(mUsageStatsManager)
+ .when(() -> LocalServices.getService(UsageStatsManagerInternal.class));
+ // Used in JobStatus.
+ doReturn(mock(PackageManagerInternal.class))
+ .when(() -> LocalServices.getService(PackageManagerInternal.class));
+
+ // Freeze the clocks at this moment in time
+ JobSchedulerService.sSystemClock =
+ Clock.fixed(Clock.systemUTC().instant(), ZoneOffset.UTC);
+ JobSchedulerService.sUptimeMillisClock =
+ Clock.fixed(SystemClock.uptimeMillisClock().instant(), ZoneOffset.UTC);
+ JobSchedulerService.sElapsedRealtimeClock =
+ Clock.fixed(SystemClock.elapsedRealtimeClock().instant(), ZoneOffset.UTC);
+
+ // Initialize real objects.
+ // Capture the listeners.
+ ArgumentCaptor<BroadcastReceiver> receiverCaptor =
+ ArgumentCaptor.forClass(BroadcastReceiver.class);
+ mQuotaController = new QuotaController(mJobSchedulerService);
+
+ verify(mContext).registerReceiver(receiverCaptor.capture(), any());
+ mChargingReceiver = receiverCaptor.getValue();
+ }
+
+ @After
+ public void tearDown() {
+ if (mMockingSession != null) {
+ mMockingSession.finishMocking();
+ }
+ }
+
+ private Clock getAdvancedClock(Clock clock, long incrementMs) {
+ return Clock.offset(clock, Duration.ofMillis(incrementMs));
+ }
+
+ private void advanceElapsedClock(long incrementMs) {
+ JobSchedulerService.sElapsedRealtimeClock = getAdvancedClock(
+ JobSchedulerService.sElapsedRealtimeClock, incrementMs);
+ }
+
+ private void setCharging() {
+ Intent intent = new Intent(BatteryManager.ACTION_CHARGING);
+ mChargingReceiver.onReceive(mContext, intent);
+ }
+
+ private void setDischarging() {
+ Intent intent = new Intent(BatteryManager.ACTION_DISCHARGING);
+ mChargingReceiver.onReceive(mContext, intent);
+ }
+
+ private void setStandbyBucket(int bucketIndex) {
+ int bucket;
+ switch (bucketIndex) {
+ case ACTIVE_INDEX:
+ bucket = UsageStatsManager.STANDBY_BUCKET_ACTIVE;
+ break;
+ case WORKING_INDEX:
+ bucket = UsageStatsManager.STANDBY_BUCKET_WORKING_SET;
+ break;
+ case FREQUENT_INDEX:
+ bucket = UsageStatsManager.STANDBY_BUCKET_FREQUENT;
+ break;
+ case RARE_INDEX:
+ bucket = UsageStatsManager.STANDBY_BUCKET_RARE;
+ break;
+ default:
+ bucket = UsageStatsManager.STANDBY_BUCKET_NEVER;
+ }
+ when(mUsageStatsManager.getAppStandbyBucket(eq(SOURCE_PACKAGE), eq(SOURCE_USER_ID),
+ anyLong())).thenReturn(bucket);
+ }
+
+ private void setStandbyBucket(int bucketIndex, JobStatus job) {
+ setStandbyBucket(bucketIndex);
+ job.setStandbyBucket(bucketIndex);
+ }
+
+ private JobStatus createJobStatus(String testTag, int jobId) {
+ JobInfo jobInfo = new JobInfo.Builder(jobId,
+ new ComponentName(mContext, "TestQuotaJobService"))
+ .setMinimumLatency(Math.abs(jobId) + 1)
+ .build();
+ return JobStatus.createFromJobInfo(
+ jobInfo, CALLING_UID, SOURCE_PACKAGE, SOURCE_USER_ID, testTag);
+ }
+
+ private TimingSession createTimingSession(long start, long duration, int count) {
+ return new TimingSession(start, start + duration, count);
+ }
+
+ @Test
+ public void testSaveTimingSession() {
+ assertNull(mQuotaController.getTimingSessions(0, "com.android.test"));
+
+ List<TimingSession> expected = new ArrayList<>();
+ TimingSession one = new TimingSession(1, 10, 1);
+ TimingSession two = new TimingSession(11, 20, 2);
+ TimingSession thr = new TimingSession(21, 30, 3);
+
+ mQuotaController.saveTimingSession(0, "com.android.test", one);
+ expected.add(one);
+ assertEquals(expected, mQuotaController.getTimingSessions(0, "com.android.test"));
+
+ mQuotaController.saveTimingSession(0, "com.android.test", two);
+ expected.add(two);
+ assertEquals(expected, mQuotaController.getTimingSessions(0, "com.android.test"));
+
+ mQuotaController.saveTimingSession(0, "com.android.test", thr);
+ expected.add(thr);
+ assertEquals(expected, mQuotaController.getTimingSessions(0, "com.android.test"));
+ }
+
+ @Test
+ public void testDeleteObsoleteSessionsLocked() {
+ final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+ TimingSession one = createTimingSession(
+ now - 10 * MINUTE_IN_MILLIS, 9 * MINUTE_IN_MILLIS, 3);
+ TimingSession two = createTimingSession(
+ now - (70 * MINUTE_IN_MILLIS), 9 * MINUTE_IN_MILLIS, 1);
+ TimingSession thr = createTimingSession(
+ now - (3 * HOUR_IN_MILLIS + 10 * MINUTE_IN_MILLIS), 9 * MINUTE_IN_MILLIS, 1);
+ // Overlaps 24 hour boundary.
+ TimingSession fou = createTimingSession(
+ now - (24 * HOUR_IN_MILLIS + 2 * MINUTE_IN_MILLIS), 7 * MINUTE_IN_MILLIS, 1);
+ // Way past the 24 hour boundary.
+ TimingSession fiv = createTimingSession(
+ now - (25 * HOUR_IN_MILLIS), 5 * MINUTE_IN_MILLIS, 4);
+ List<TimingSession> expected = new ArrayList<>();
+ // Added in correct (chronological) order.
+ expected.add(fou);
+ expected.add(thr);
+ expected.add(two);
+ expected.add(one);
+ mQuotaController.saveTimingSession(0, "com.android.test", fiv);
+ mQuotaController.saveTimingSession(0, "com.android.test", fou);
+ mQuotaController.saveTimingSession(0, "com.android.test", thr);
+ mQuotaController.saveTimingSession(0, "com.android.test", two);
+ mQuotaController.saveTimingSession(0, "com.android.test", one);
+
+ mQuotaController.deleteObsoleteSessionsLocked();
+
+ assertEquals(expected, mQuotaController.getTimingSessions(0, "com.android.test"));
+ }
+
+ @Test
+ public void testGetTrailingExecutionTimeLocked_NoTimer() {
+ final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+ // Added in chronological order.
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - (6 * HOUR_IN_MILLIS), 10 * MINUTE_IN_MILLIS, 5));
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(
+ now - (2 * HOUR_IN_MILLIS + MINUTE_IN_MILLIS), 6 * MINUTE_IN_MILLIS, 5));
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - (HOUR_IN_MILLIS), MINUTE_IN_MILLIS, 1));
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(
+ now - (HOUR_IN_MILLIS - 10 * MINUTE_IN_MILLIS), MINUTE_IN_MILLIS, 1));
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 5 * MINUTE_IN_MILLIS, 4 * MINUTE_IN_MILLIS, 3));
+
+ assertEquals(0, mQuotaController.getTrailingExecutionTimeLocked(0, "com.android.test",
+ MINUTE_IN_MILLIS));
+ assertEquals(2 * MINUTE_IN_MILLIS,
+ mQuotaController.getTrailingExecutionTimeLocked(0, "com.android.test",
+ 3 * MINUTE_IN_MILLIS));
+ assertEquals(4 * MINUTE_IN_MILLIS,
+ mQuotaController.getTrailingExecutionTimeLocked(0, "com.android.test",
+ 5 * MINUTE_IN_MILLIS));
+ assertEquals(4 * MINUTE_IN_MILLIS,
+ mQuotaController.getTrailingExecutionTimeLocked(0, "com.android.test",
+ 49 * MINUTE_IN_MILLIS));
+ assertEquals(5 * MINUTE_IN_MILLIS,
+ mQuotaController.getTrailingExecutionTimeLocked(0, "com.android.test",
+ 50 * MINUTE_IN_MILLIS));
+ assertEquals(6 * MINUTE_IN_MILLIS,
+ mQuotaController.getTrailingExecutionTimeLocked(0, "com.android.test",
+ HOUR_IN_MILLIS));
+ assertEquals(11 * MINUTE_IN_MILLIS,
+ mQuotaController.getTrailingExecutionTimeLocked(0, "com.android.test",
+ 2 * HOUR_IN_MILLIS));
+ assertEquals(12 * MINUTE_IN_MILLIS,
+ mQuotaController.getTrailingExecutionTimeLocked(0, "com.android.test",
+ 3 * HOUR_IN_MILLIS));
+ assertEquals(22 * MINUTE_IN_MILLIS,
+ mQuotaController.getTrailingExecutionTimeLocked(0, "com.android.test",
+ 6 * HOUR_IN_MILLIS));
+ }
+
+ @Test
+ public void testMaybeScheduleCleanupAlarmLocked() {
+ // No sessions saved yet.
+ mQuotaController.maybeScheduleCleanupAlarmLocked();
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_CLEANUP), any(), any());
+
+ // Test with only one timing session saved.
+ final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+ final long end = now - (6 * HOUR_IN_MILLIS - 5 * MINUTE_IN_MILLIS);
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ new TimingSession(now - 6 * HOUR_IN_MILLIS, end, 1));
+ mQuotaController.maybeScheduleCleanupAlarmLocked();
+ verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(end + 24 * HOUR_IN_MILLIS), eq(TAG_CLEANUP), any(), any());
+
+ // Test with new (more recent) timing sessions saved. AlarmManger shouldn't be called again.
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 3 * HOUR_IN_MILLIS, MINUTE_IN_MILLIS, 1));
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - HOUR_IN_MILLIS, 3 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleCleanupAlarmLocked();
+ verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(end + 24 * HOUR_IN_MILLIS), eq(TAG_CLEANUP), any(), any());
+ }
+
+ @Test
+ public void testMaybeScheduleStartAlarmLocked_WorkingSet() {
+ // 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();
+
+ // Working set window size is 2 hours.
+ final int standbyBucket = WORKING_INDEX;
+
+ // No sessions saved yet.
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Test with timing sessions out of window.
+ final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 10 * HOUR_IN_MILLIS, 5 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Test with timing sessions in window but still in quota.
+ final long end = now - (2 * HOUR_IN_MILLIS - 5 * MINUTE_IN_MILLIS);
+ // Counting backwards, the quota will come back one minute before the end.
+ final long expectedAlarmTime =
+ end - MINUTE_IN_MILLIS + 2 * HOUR_IN_MILLIS + IN_QUOTA_BUFFER_MILLIS;
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ new TimingSession(now - 2 * HOUR_IN_MILLIS, end, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Add some more sessions, but still in quota.
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - HOUR_IN_MILLIS, MINUTE_IN_MILLIS, 1));
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - (50 * MINUTE_IN_MILLIS), 3 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Test when out of quota.
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 30 * MINUTE_IN_MILLIS, 5 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Alarm already scheduled, so make sure it's not scheduled again.
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+ }
+
+ @Test
+ public void testMaybeScheduleStartAlarmLocked_Frequent() {
+ // 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();
+
+ // Frequent window size is 8 hours.
+ final int standbyBucket = FREQUENT_INDEX;
+
+ // No sessions saved yet.
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Test with timing sessions out of window.
+ final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 10 * HOUR_IN_MILLIS, 5 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Test with timing sessions in window but still in quota.
+ final long start = now - (6 * HOUR_IN_MILLIS);
+ final long expectedAlarmTime = start + 8 * HOUR_IN_MILLIS + IN_QUOTA_BUFFER_MILLIS;
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(start, 5 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Add some more sessions, but still in quota.
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 3 * HOUR_IN_MILLIS, MINUTE_IN_MILLIS, 1));
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - HOUR_IN_MILLIS, 3 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Test when out of quota.
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - HOUR_IN_MILLIS, MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Alarm already scheduled, so make sure it's not scheduled again.
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+ }
+
+ @Test
+ public void testMaybeScheduleStartAlarmLocked_Rare() {
+ // 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();
+
+ // Rare window size is 24 hours.
+ final int standbyBucket = RARE_INDEX;
+
+ // No sessions saved yet.
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Test with timing sessions out of window.
+ final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 25 * HOUR_IN_MILLIS, 5 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Test with timing sessions in window but still in quota.
+ final long start = now - (6 * HOUR_IN_MILLIS);
+ // Counting backwards, the first minute in the session is over the allowed time, so it
+ // needs to be excluded.
+ final long expectedAlarmTime =
+ start + MINUTE_IN_MILLIS + 24 * HOUR_IN_MILLIS + IN_QUOTA_BUFFER_MILLIS;
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(start, 5 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Add some more sessions, but still in quota.
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 3 * HOUR_IN_MILLIS, MINUTE_IN_MILLIS, 1));
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - HOUR_IN_MILLIS, 3 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Test when out of quota.
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - HOUR_IN_MILLIS, 2 * MINUTE_IN_MILLIS, 1));
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // Alarm already scheduled, so make sure it's not scheduled again.
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", standbyBucket);
+ verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+ }
+
+ /** Tests that the start alarm is properly rescheduled if the app's bucket is changed. */
+ @Test
+ public void testMaybeScheduleStartAlarmLocked_BucketChange() {
+ // 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();
+
+ final long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+
+ // Affects rare bucket
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 12 * HOUR_IN_MILLIS, 9 * MINUTE_IN_MILLIS, 3));
+ // Affects frequent and rare buckets
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 4 * HOUR_IN_MILLIS, 4 * MINUTE_IN_MILLIS, 3));
+ // Affects working, frequent, and rare buckets
+ final long outOfQuotaTime = now - HOUR_IN_MILLIS;
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(outOfQuotaTime, 7 * MINUTE_IN_MILLIS, 10));
+ // Affects all buckets
+ mQuotaController.saveTimingSession(0, "com.android.test",
+ createTimingSession(now - 5 * MINUTE_IN_MILLIS, 3 * MINUTE_IN_MILLIS, 3));
+
+ InOrder inOrder = inOrder(mAlarmManager);
+
+ // Start in ACTIVE bucket.
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", ACTIVE_INDEX);
+ inOrder.verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(),
+ any());
+ inOrder.verify(mAlarmManager, never()).cancel(any(AlarmManager.OnAlarmListener.class));
+
+ // And down from there.
+ final long expectedWorkingAlarmTime =
+ outOfQuotaTime + (2 * HOUR_IN_MILLIS) + IN_QUOTA_BUFFER_MILLIS;
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", WORKING_INDEX);
+ inOrder.verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedWorkingAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+
+ final long expectedFrequentAlarmTime =
+ outOfQuotaTime + (8 * HOUR_IN_MILLIS) + IN_QUOTA_BUFFER_MILLIS;
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", FREQUENT_INDEX);
+ inOrder.verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedFrequentAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+
+ final long expectedRareAlarmTime =
+ outOfQuotaTime + (24 * HOUR_IN_MILLIS) + IN_QUOTA_BUFFER_MILLIS;
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", RARE_INDEX);
+ inOrder.verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedRareAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+
+ // And back up again.
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", FREQUENT_INDEX);
+ inOrder.verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedFrequentAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", WORKING_INDEX);
+ inOrder.verify(mAlarmManager, times(1))
+ .set(anyInt(), eq(expectedWorkingAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
+
+ mQuotaController.maybeScheduleStartAlarmLocked(0, "com.android.test", ACTIVE_INDEX);
+ inOrder.verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(),
+ any());
+ inOrder.verify(mAlarmManager, times(1)).cancel(any(AlarmManager.OnAlarmListener.class));
+ }
+
+ /** Tests that QuotaController doesn't throttle if throttling is turned off. */
+ @Test
+ public void testThrottleToggling() throws Exception {
+ setDischarging();
+ mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE,
+ createTimingSession(
+ JobSchedulerService.sElapsedRealtimeClock.millis() - HOUR_IN_MILLIS,
+ 10 * MINUTE_IN_MILLIS, 4));
+ JobStatus jobStatus = createJobStatus("testThrottleToggling", 1);
+ setStandbyBucket(WORKING_INDEX, jobStatus); // 2 hour window
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus, null);
+ assertFalse(jobStatus.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
+
+ mConstants.USE_HEARTBEATS = true;
+ mQuotaController.onConstantsUpdatedLocked();
+ Thread.sleep(SECOND_IN_MILLIS); // Job updates are done in the background.
+ assertTrue(jobStatus.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
+
+ mConstants.USE_HEARTBEATS = false;
+ mQuotaController.onConstantsUpdatedLocked();
+ Thread.sleep(SECOND_IN_MILLIS); // Job updates are done in the background.
+ assertFalse(jobStatus.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
+ }
+
+ @Test
+ public void testConstantsUpdating_ValidValues() {
+ mConstants.QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS = 5 * MINUTE_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS = 2 * MINUTE_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS = 15 * MINUTE_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS = 30 * MINUTE_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS = 45 * MINUTE_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS = 60 * MINUTE_IN_MILLIS;
+
+ mQuotaController.onConstantsUpdatedLocked();
+
+ assertEquals(5 * MINUTE_IN_MILLIS, mQuotaController.getAllowedTimePerPeriodMs());
+ assertEquals(2 * MINUTE_IN_MILLIS, mQuotaController.getInQuotaBufferMs());
+ assertEquals(15 * MINUTE_IN_MILLIS, mQuotaController.getBucketWindowSizes()[ACTIVE_INDEX]);
+ assertEquals(30 * MINUTE_IN_MILLIS, mQuotaController.getBucketWindowSizes()[WORKING_INDEX]);
+ assertEquals(45 * MINUTE_IN_MILLIS,
+ mQuotaController.getBucketWindowSizes()[FREQUENT_INDEX]);
+ assertEquals(60 * MINUTE_IN_MILLIS, mQuotaController.getBucketWindowSizes()[RARE_INDEX]);
+ }
+
+ @Test
+ public void testConstantsUpdating_InvalidValues() {
+ // Test negatives
+ mConstants.QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS = -MINUTE_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS = -MINUTE_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS = -MINUTE_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS = -MINUTE_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS = -MINUTE_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS = -MINUTE_IN_MILLIS;
+
+ mQuotaController.onConstantsUpdatedLocked();
+
+ assertEquals(MINUTE_IN_MILLIS, mQuotaController.getAllowedTimePerPeriodMs());
+ assertEquals(0, mQuotaController.getInQuotaBufferMs());
+ assertEquals(MINUTE_IN_MILLIS, mQuotaController.getBucketWindowSizes()[ACTIVE_INDEX]);
+ assertEquals(MINUTE_IN_MILLIS, mQuotaController.getBucketWindowSizes()[WORKING_INDEX]);
+ assertEquals(MINUTE_IN_MILLIS, mQuotaController.getBucketWindowSizes()[FREQUENT_INDEX]);
+ assertEquals(MINUTE_IN_MILLIS, mQuotaController.getBucketWindowSizes()[RARE_INDEX]);
+
+ // Test larger than a day. Controller should cap at one day.
+ mConstants.QUOTA_CONTROLLER_ALLOWED_TIME_PER_PERIOD_MS = 25 * HOUR_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_IN_QUOTA_BUFFER_MS = 25 * HOUR_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_ACTIVE_MS = 25 * HOUR_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_WORKING_MS = 25 * HOUR_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_FREQUENT_MS = 25 * HOUR_IN_MILLIS;
+ mConstants.QUOTA_CONTROLLER_WINDOW_SIZE_RARE_MS = 25 * HOUR_IN_MILLIS;
+
+ mQuotaController.onConstantsUpdatedLocked();
+
+ assertEquals(24 * HOUR_IN_MILLIS, mQuotaController.getAllowedTimePerPeriodMs());
+ assertEquals(5 * MINUTE_IN_MILLIS, mQuotaController.getInQuotaBufferMs());
+ assertEquals(24 * HOUR_IN_MILLIS, mQuotaController.getBucketWindowSizes()[ACTIVE_INDEX]);
+ assertEquals(24 * HOUR_IN_MILLIS, mQuotaController.getBucketWindowSizes()[WORKING_INDEX]);
+ assertEquals(24 * HOUR_IN_MILLIS, mQuotaController.getBucketWindowSizes()[FREQUENT_INDEX]);
+ assertEquals(24 * HOUR_IN_MILLIS, mQuotaController.getBucketWindowSizes()[RARE_INDEX]);
+ }
+
+ /** Tests that TimingSessions aren't saved when the device is charging. */
+ @Test
+ public void testTimerTracking_Charging() {
+ setCharging();
+
+ JobStatus jobStatus = createJobStatus("testTimerTracking_Charging", 1);
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus, null);
+
+ assertNull(mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE));
+
+ mQuotaController.prepareForExecutionLocked(jobStatus);
+ advanceElapsedClock(5 * SECOND_IN_MILLIS);
+ mQuotaController.maybeStopTrackingJobLocked(jobStatus, null, false);
+ assertNull(mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE));
+ }
+
+ /** Tests that TimingSessions are saved properly when the device is discharging. */
+ @Test
+ public void testTimerTracking_Discharging() {
+ setDischarging();
+
+ JobStatus jobStatus = createJobStatus("testTimerTracking_Discharging", 1);
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus, null);
+
+ assertNull(mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE));
+
+ List<TimingSession> expected = new ArrayList<>();
+
+ long start = JobSchedulerService.sElapsedRealtimeClock.millis();
+ mQuotaController.prepareForExecutionLocked(jobStatus);
+ advanceElapsedClock(5 * SECOND_IN_MILLIS);
+ mQuotaController.maybeStopTrackingJobLocked(jobStatus, null, false);
+ expected.add(createTimingSession(start, 5 * SECOND_IN_MILLIS, 1));
+ assertEquals(expected, mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE));
+
+ // Test overlapping jobs.
+ JobStatus jobStatus2 = createJobStatus("testTimerTracking_Discharging", 2);
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus2, null);
+
+ JobStatus jobStatus3 = createJobStatus("testTimerTracking_Discharging", 3);
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus3, null);
+
+ advanceElapsedClock(SECOND_IN_MILLIS);
+
+ start = JobSchedulerService.sElapsedRealtimeClock.millis();
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus, null);
+ mQuotaController.prepareForExecutionLocked(jobStatus);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ mQuotaController.prepareForExecutionLocked(jobStatus2);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ mQuotaController.maybeStopTrackingJobLocked(jobStatus, null, false);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ mQuotaController.prepareForExecutionLocked(jobStatus3);
+ advanceElapsedClock(20 * SECOND_IN_MILLIS);
+ mQuotaController.maybeStopTrackingJobLocked(jobStatus3, null, false);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ mQuotaController.maybeStopTrackingJobLocked(jobStatus2, null, false);
+ expected.add(createTimingSession(start, MINUTE_IN_MILLIS, 3));
+ assertEquals(expected, mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE));
+ }
+
+ /**
+ * Tests that TimingSessions are saved properly when the device alternates between
+ * charging and discharging.
+ */
+ @Test
+ public void testTimerTracking_ChargingAndDischarging() {
+ JobStatus jobStatus = createJobStatus("testTimerTracking_ChargingAndDischarging", 1);
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus, null);
+ JobStatus jobStatus2 = createJobStatus("testTimerTracking_ChargingAndDischarging", 2);
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus2, null);
+ JobStatus jobStatus3 = createJobStatus("testTimerTracking_ChargingAndDischarging", 3);
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus3, null);
+ assertNull(mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE));
+ List<TimingSession> expected = new ArrayList<>();
+
+ // A job starting while charging. Only the portion that runs during the discharging period
+ // should be counted.
+ setCharging();
+
+ mQuotaController.prepareForExecutionLocked(jobStatus);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ setDischarging();
+ long start = JobSchedulerService.sElapsedRealtimeClock.millis();
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ mQuotaController.maybeStopTrackingJobLocked(jobStatus, jobStatus, true);
+ expected.add(createTimingSession(start, 10 * SECOND_IN_MILLIS, 1));
+ assertEquals(expected, mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE));
+
+ advanceElapsedClock(SECOND_IN_MILLIS);
+
+ // One job starts while discharging, spans a charging session, and ends after the charging
+ // session. Only the portions during the discharging periods should be counted. This should
+ // result in two TimingSessions. A second job starts while discharging and ends within the
+ // charging session. Only the portion during the first discharging portion should be
+ // counted. A third job starts and ends within the charging session. The third job
+ // shouldn't be included in either job count.
+ setDischarging();
+ start = JobSchedulerService.sElapsedRealtimeClock.millis();
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus, null);
+ mQuotaController.prepareForExecutionLocked(jobStatus);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ mQuotaController.prepareForExecutionLocked(jobStatus2);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ setCharging();
+ expected.add(createTimingSession(start, 20 * SECOND_IN_MILLIS, 2));
+ mQuotaController.prepareForExecutionLocked(jobStatus3);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ mQuotaController.maybeStopTrackingJobLocked(jobStatus3, null, false);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ mQuotaController.maybeStopTrackingJobLocked(jobStatus2, null, false);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ setDischarging();
+ start = JobSchedulerService.sElapsedRealtimeClock.millis();
+ advanceElapsedClock(20 * SECOND_IN_MILLIS);
+ mQuotaController.maybeStopTrackingJobLocked(jobStatus, null, false);
+ expected.add(createTimingSession(start, 20 * SECOND_IN_MILLIS, 1));
+ assertEquals(expected, mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE));
+
+ // A job starting while discharging and ending while charging. Only the portion that runs
+ // during the discharging period should be counted.
+ setDischarging();
+ start = JobSchedulerService.sElapsedRealtimeClock.millis();
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus2, null);
+ mQuotaController.prepareForExecutionLocked(jobStatus2);
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ expected.add(createTimingSession(start, 10 * SECOND_IN_MILLIS, 1));
+ setCharging();
+ advanceElapsedClock(10 * SECOND_IN_MILLIS);
+ mQuotaController.maybeStopTrackingJobLocked(jobStatus2, null, false);
+ assertEquals(expected, mQuotaController.getTimingSessions(SOURCE_USER_ID, SOURCE_PACKAGE));
+ }
+
+ /**
+ * Tests that a job is properly updated and JobSchedulerService is notified when a job reaches
+ * its quota.
+ */
+ @Test
+ public void testTracking_OutOfQuota() {
+ JobStatus jobStatus = createJobStatus("testTracking_OutOfQuota", 1);
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus, null);
+ setStandbyBucket(WORKING_INDEX, jobStatus); // 2 hour window
+ // Now the package only has two seconds to run.
+ final long remainingTimeMs = 2 * SECOND_IN_MILLIS;
+ mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE,
+ createTimingSession(
+ JobSchedulerService.sElapsedRealtimeClock.millis() - HOUR_IN_MILLIS,
+ 10 * MINUTE_IN_MILLIS - remainingTimeMs, 1));
+
+ // Start the job.
+ mQuotaController.prepareForExecutionLocked(jobStatus);
+ advanceElapsedClock(remainingTimeMs);
+
+ // Wait for some extra time to allow for job processing.
+ verify(mJobSchedulerService,
+ timeout(remainingTimeMs + 2 * SECOND_IN_MILLIS).times(1))
+ .onControllerStateChanged();
+ assertFalse(jobStatus.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
+ }
+
+ /**
+ * Tests that a job is properly handled when it's at the edge of its quota and the old quota is
+ * being phased out.
+ */
+ @Test
+ public void testTracking_RollingQuota() {
+ JobStatus jobStatus = createJobStatus("testTracking_OutOfQuota", 1);
+ mQuotaController.maybeStartTrackingJobLocked(jobStatus, null);
+ setStandbyBucket(WORKING_INDEX, jobStatus); // 2 hour window
+ Handler handler = mQuotaController.getHandler();
+ spyOn(handler);
+
+ long now = JobSchedulerService.sElapsedRealtimeClock.millis();
+ final long remainingTimeMs = SECOND_IN_MILLIS;
+ // The package only has one second to run, but this session is at the edge of the rolling
+ // window, so as the package "reaches its quota" it will have more to keep running.
+ mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE,
+ createTimingSession(now - 2 * HOUR_IN_MILLIS,
+ 10 * MINUTE_IN_MILLIS - remainingTimeMs, 1));
+
+ assertEquals(remainingTimeMs, mQuotaController.getRemainingExecutionTimeLocked(jobStatus));
+ // Start the job.
+ mQuotaController.prepareForExecutionLocked(jobStatus);
+ advanceElapsedClock(remainingTimeMs);
+
+ // Wait for some extra time to allow for job processing.
+ verify(mJobSchedulerService,
+ timeout(remainingTimeMs + 2 * SECOND_IN_MILLIS).times(0))
+ .onControllerStateChanged();
+ assertTrue(jobStatus.isConstraintSatisfied(JobStatus.CONSTRAINT_WITHIN_QUOTA));
+ // The job used up the remaining quota, but in that time, the same amount of time in the
+ // old TimingSession also fell out of the quota window, so it should still have the same
+ // amount of remaining time left its quota.
+ assertEquals(remainingTimeMs,
+ mQuotaController.getRemainingExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE));
+ verify(handler, atLeast(1)).sendMessageDelayed(any(), eq(remainingTimeMs));
+ }
+}