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