Core QuotaController implementation.
This is the base implementation for QuotaController that will allow apps
to run their jobs in a rolling window. The feature is off by default,
but if turned on in its current state, will allow jobs that don't require
connectivity to run and use up quota. Quota is currently only tracked
using job run times, but is set up to also use job run counts as well.
Support for network dependent jobs will be in a separate CL.
Bug: 117846754
Bug: 111423978
Test: atest com.android.server.job.controllers.QuotaControllerTest
Change-Id: I24caf8ff5f6339e296dd1feec2359679a728d33a
diff --git a/core/proto/android/server/jobscheduler.proto b/core/proto/android/server/jobscheduler.proto
index e83a2bf..231caab 100644
--- a/core/proto/android/server/jobscheduler.proto
+++ b/core/proto/android/server/jobscheduler.proto
@@ -215,6 +215,34 @@
// 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 @@
}
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 @@
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 @@
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 @@
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 @@
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 @@
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 b3f0629..ea295de 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 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.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 @@
* 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 @@
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 @@
}
}
- // 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 @@
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 @@
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 @@
* 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 @@
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 @@
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 @@
}
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 @@
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 @@
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 @@
// 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 @@
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 @@
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]);
+ Slog.v(TAG, "isReadyToBeExecutedLocked: " + job.toShortString()
+ + " parole=" + mInParole + " active=" + job.uidActive
+ + " exempt=" + job.getJob().isExemptedFromAppStandby());
}
- 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);
+ 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());
}
- job.setWhenStandbyDeferred(sElapsedRealtimeClock.millis());
- }
- return false;
- } else {
- if (DEBUG_STANDBY) {
- Slog.v(TAG, "Bucket deferred job aged into runnability at "
- + mHeartbeat + " : " + job);
+ return false;
+ } else {
+ if (DEBUG_STANDBY) {
+ Slog.v(TAG, "Bucket deferred job aged into runnability at "
+ + mHeartbeat + " : " + job);
+ }
}
}
}
@@ -2364,32 +2505,7 @@
@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 35fc29e..6deecbd 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 @@
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 @@
* 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 @@
*/
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 @@
return baseHeartbeat;
}
- // Called only by the standby monitoring code
public void setStandbyBucket(int newBucket) {
standbyBucket = newBucket;
}
@@ -876,22 +883,27 @@
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 @@
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 @@
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 @@
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 @@
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 @@
* @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 @@
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 @@
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 @@
* 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 @@
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 @@
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 @@
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 @@
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 @@
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 0000000..f73ffac
--- /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 c2be283..b439c0d 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 @@
* 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 0000000..b2ec835
--- /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));
+ }
+}