| /* |
| * 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.ActivityManager; |
| import android.app.ActivityManagerInternal; |
| import android.app.AlarmManager; |
| import android.app.IUidObserver; |
| 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.RemoteException; |
| 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.SparseBooleanArray; |
| 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.List; |
| import java.util.Objects; |
| import java.util.function.Consumer; |
| import java.util.function.Predicate; |
| |
| /** |
| * Controller that tracks whether an app 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. |
| * |
| * Jobs are throttled while an app is not in a foreground state. All jobs are allowed to run |
| * freely when an app enters the foreground state and are restricted when the app leaves the |
| * foreground state. However, jobs that are started while the app is in the TOP state are not |
| * restricted regardless of the app's state change. |
| * |
| * 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); |
| } |
| |
| public void clear() { |
| for (int i = 0; i < mData.size(); ++i) { |
| mData.valueAt(i).clear(); |
| } |
| } |
| |
| /** Removes all the data for the user, if there was any. */ |
| public void delete(int userId) { |
| mData.delete(userId); |
| } |
| |
| /** Removes the data for the user and package, if there was any. */ |
| public void delete(int userId, @NonNull String packageName) { |
| ArrayMap<String, T> data = mData.get(userId); |
| if (data != null) { |
| data.remove(packageName); |
| } |
| } |
| |
| @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; |
| } |
| |
| /** @see SparseArray#indexOfKey */ |
| public int indexOfKey(int userId) { |
| return mData.indexOfKey(userId); |
| } |
| |
| /** 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; |
| } |
| |
| private 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; |
| } |
| } |
| |
| private static int hashLong(long val) { |
| return (int) (val ^ (val >>> 32)); |
| } |
| |
| @VisibleForTesting |
| static class ExecutionStats { |
| /** |
| * The time after which this record should be considered invalid (out of date), in the |
| * elapsed realtime timebase. |
| */ |
| public long expirationTimeElapsed; |
| |
| public long windowSizeMs; |
| |
| /** The total amount of time the app ran in its respective bucket window size. */ |
| public long executionTimeInWindowMs; |
| public int bgJobCountInWindow; |
| |
| /** The total amount of time the app ran in the last {@link #MAX_PERIOD_MS}. */ |
| public long executionTimeInMaxPeriodMs; |
| public int bgJobCountInMaxPeriod; |
| |
| /** |
| * The time after which the sum of all the app's sessions plus {@link #mQuotaBufferMs} |
| * equals the quota. This is only valid if |
| * executionTimeInWindowMs >= {@link #mAllowedTimePerPeriodMs} or |
| * executionTimeInMaxPeriodMs >= {@link #mMaxExecutionTimeMs}. |
| */ |
| public long quotaCutoffTimeElapsed; |
| |
| /** |
| * The time after which {@link #jobCountInAllowedTime} should be considered invalid, in the |
| * elapsed realtime timebase. |
| */ |
| public long jobCountExpirationTimeElapsed; |
| |
| /** |
| * The number of jobs that ran in at least the last {@link #mAllowedTimePerPeriodMs}. |
| * It may contain a few stale entries since cleanup won't happen exactly every |
| * {@link #mAllowedTimePerPeriodMs}. |
| */ |
| public int jobCountInAllowedTime; |
| |
| @Override |
| public String toString() { |
| return new StringBuilder() |
| .append("expirationTime=").append(expirationTimeElapsed).append(", ") |
| .append("windowSize=").append(windowSizeMs).append(", ") |
| .append("executionTimeInWindow=").append(executionTimeInWindowMs).append(", ") |
| .append("bgJobCountInWindow=").append(bgJobCountInWindow).append(", ") |
| .append("executionTimeInMaxPeriod=").append(executionTimeInMaxPeriodMs) |
| .append(", ") |
| .append("bgJobCountInMaxPeriod=").append(bgJobCountInMaxPeriod).append(", ") |
| .append("quotaCutoffTime=").append(quotaCutoffTimeElapsed).append(", ") |
| .append("jobCountExpirationTime").append(jobCountExpirationTimeElapsed) |
| .append(", ") |
| .append("jobCountInAllowedTime").append(jobCountInAllowedTime) |
| .toString(); |
| } |
| |
| @Override |
| public boolean equals(Object obj) { |
| if (obj instanceof ExecutionStats) { |
| ExecutionStats other = (ExecutionStats) obj; |
| return this.expirationTimeElapsed == other.expirationTimeElapsed |
| && this.windowSizeMs == other.windowSizeMs |
| && this.executionTimeInWindowMs == other.executionTimeInWindowMs |
| && this.bgJobCountInWindow == other.bgJobCountInWindow |
| && this.executionTimeInMaxPeriodMs == other.executionTimeInMaxPeriodMs |
| && this.bgJobCountInMaxPeriod == other.bgJobCountInMaxPeriod |
| && this.quotaCutoffTimeElapsed == other.quotaCutoffTimeElapsed |
| && this.jobCountExpirationTimeElapsed == other.jobCountExpirationTimeElapsed |
| && this.jobCountInAllowedTime == other.jobCountInAllowedTime; |
| } else { |
| return false; |
| } |
| } |
| |
| @Override |
| public int hashCode() { |
| int result = 0; |
| result = 31 * result + hashLong(expirationTimeElapsed); |
| result = 31 * result + hashLong(windowSizeMs); |
| result = 31 * result + hashLong(executionTimeInWindowMs); |
| result = 31 * result + bgJobCountInWindow; |
| result = 31 * result + hashLong(executionTimeInMaxPeriodMs); |
| result = 31 * result + bgJobCountInMaxPeriod; |
| result = 31 * result + hashLong(quotaCutoffTimeElapsed); |
| result = 31 * result + hashLong(jobCountExpirationTimeElapsed); |
| result = 31 * result + jobCountInAllowedTime; |
| return result; |
| } |
| } |
| |
| /** 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<>(); |
| |
| /** Cached calculation results for each app, with the standby buckets as the array indices. */ |
| private final UserPackageMap<ExecutionStats[]> mExecutionStatsCache = new UserPackageMap<>(); |
| |
| /** List of UIDs currently in the foreground. */ |
| private final SparseBooleanArray mForegroundUids = new SparseBooleanArray(); |
| |
| /** |
| * List of jobs that started while the UID was in the TOP state. There will be no more than |
| * 16 ({@link JobSchedulerService#MAX_JOB_CONTEXTS_COUNT}) running at once, so an ArraySet is |
| * fine. |
| */ |
| private final ArraySet<JobStatus> mTopStartedJobs = new ArraySet<>(); |
| |
| private final ActivityManagerInternal mActivityManagerInternal; |
| 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; |
| |
| /** |
| * The maximum amount of time an app can have its jobs running within a {@link #MAX_PERIOD_MS} |
| * window. |
| */ |
| private long mMaxExecutionTimeMs = 4 * 60 * MINUTE_IN_MILLIS; |
| |
| /** |
| * How much time the app should have before transitioning from out-of-quota to in-quota. |
| * This should not affect processing if the app is already in-quota. |
| */ |
| private long mQuotaBufferMs = 30 * 1000L; // 30 seconds |
| |
| /** |
| * {@link #mAllowedTimePerPeriodMs} - {@link #mQuotaBufferMs}. This can be used to determine |
| * when an app will have enough quota to transition from out-of-quota to in-quota. |
| */ |
| private long mAllowedTimeIntoQuotaMs = mAllowedTimePerPeriodMs - mQuotaBufferMs; |
| |
| /** |
| * {@link #mMaxExecutionTimeMs} - {@link #mQuotaBufferMs}. This can be used to determine when an |
| * app will have enough quota to transition from out-of-quota to in-quota. |
| */ |
| private long mMaxExecutionTimeIntoQuotaMs = mMaxExecutionTimeMs - mQuotaBufferMs; |
| |
| /** The maximum number of jobs that can run within the past {@link #mAllowedTimePerPeriodMs}. */ |
| private int mMaxJobCountPerAllowedTime = 20; |
| |
| private long mNextCleanupTimeElapsed = 0; |
| private final AlarmManager.OnAlarmListener mSessionCleanupAlarmListener = |
| new AlarmManager.OnAlarmListener() { |
| @Override |
| public void onAlarm() { |
| mHandler.obtainMessage(MSG_CLEAN_UP_SESSIONS).sendToTarget(); |
| } |
| }; |
| |
| private final IUidObserver mUidObserver = new IUidObserver.Stub() { |
| @Override |
| public void onUidStateChanged(int uid, int procState, long procStateSeq) { |
| mHandler.obtainMessage(MSG_UID_PROCESS_STATE_CHANGED, uid, procState).sendToTarget(); |
| } |
| |
| @Override |
| public void onUidGone(int uid, boolean disabled) { |
| } |
| |
| @Override |
| public void onUidActive(int uid) { |
| } |
| |
| @Override |
| public void onUidIdle(int uid, boolean disabled) { |
| } |
| |
| @Override |
| public void onUidCachedChanged(int uid, boolean cached) { |
| } |
| }; |
| |
| /** |
| * 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; |
| |
| /** |
| * The maximum number of jobs based on its standby bucket. For each max value count in the |
| * array, the app will not be allowed to run more than that many number of jobs within the |
| * latest time interval of its rolling window size. |
| * |
| * @see #mBucketPeriodsMs |
| */ |
| private final int[] mMaxBucketJobCounts = new int[] { |
| 200, // ACTIVE -- 1200/hr |
| 1200, // WORKING -- 600/hr |
| 1800, // FREQUENT -- 225/hr |
| 2400 // RARE -- 100/hr |
| }; |
| |
| /** The minimum number of jobs that any bucket will be allowed to run. */ |
| private static final int MIN_BUCKET_JOB_COUNT = 100; |
| |
| /** An app has reached its quota. The message should contain a {@link Package} object. */ |
| private static final int MSG_REACHED_QUOTA = 0; |
| /** Drop any old timing sessions. */ |
| 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; |
| /** Process state for a UID has changed. */ |
| private static final int MSG_UID_PROCESS_STATE_CHANGED = 3; |
| |
| public QuotaController(JobSchedulerService service) { |
| super(service); |
| mHandler = new QcHandler(mContext.getMainLooper()); |
| mChargeTracker = new ChargingTracker(); |
| mChargeTracker.startTracking(); |
| mActivityManagerInternal = LocalServices.getService(ActivityManagerInternal.class); |
| mAlarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); |
| |
| // Set up the app standby bucketing tracker |
| UsageStatsManagerInternal usageStats = LocalServices.getService( |
| UsageStatsManagerInternal.class); |
| usageStats.addAppIdleStateChangeListener(new StandbyTracker()); |
| |
| try { |
| ActivityManager.getService().registerUidObserver(mUidObserver, |
| ActivityManager.UID_OBSERVER_PROCSTATE, |
| ActivityManager.PROCESS_STATE_FOREGROUND_SERVICE, null); |
| } catch (RemoteException e) { |
| // ignored; both services live in system_server |
| } |
| |
| 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 uid = jobStatus.getSourceUid(); |
| if (mActivityManagerInternal.getUidProcessState(uid) <= ActivityManager.PROCESS_STATE_TOP) { |
| mTopStartedJobs.add(jobStatus); |
| // Top jobs won't count towards quota so there's no need to involve the Timer. |
| return; |
| } |
| |
| final int userId = jobStatus.getSourceUserId(); |
| final String packageName = jobStatus.getSourcePackageName(); |
| Timer timer = mPkgTimers.get(userId, packageName); |
| if (timer == null) { |
| timer = new Timer(uid, 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); |
| } |
| mTopStartedJobs.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; |
| mAllowedTimeIntoQuotaMs = mAllowedTimePerPeriodMs - mQuotaBufferMs; |
| 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; |
| mAllowedTimeIntoQuotaMs = mAllowedTimePerPeriodMs - mQuotaBufferMs; |
| mMaxExecutionTimeIntoQuotaMs = mMaxExecutionTimeMs - mQuotaBufferMs; |
| 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; |
| } |
| long newMaxExecutionTimeMs = Math.max(60 * MINUTE_IN_MILLIS, |
| Math.min(MAX_PERIOD_MS, mConstants.QUOTA_CONTROLLER_MAX_EXECUTION_TIME_MS)); |
| if (mMaxExecutionTimeMs != newMaxExecutionTimeMs) { |
| mMaxExecutionTimeMs = newMaxExecutionTimeMs; |
| mMaxExecutionTimeIntoQuotaMs = mMaxExecutionTimeMs - mQuotaBufferMs; |
| changed = true; |
| } |
| int newMaxCountPerAllowedPeriod = Math.max(10, |
| mConstants.QUOTA_CONTROLLER_MAX_JOB_COUNT_PER_ALLOWED_TIME); |
| if (mMaxJobCountPerAllowedTime != newMaxCountPerAllowedPeriod) { |
| mMaxJobCountPerAllowedTime = newMaxCountPerAllowedPeriod; |
| changed = true; |
| } |
| int newActiveMaxJobCount = Math.max(mMaxJobCountPerAllowedTime, |
| Math.max(MIN_BUCKET_JOB_COUNT, mConstants.QUOTA_CONTROLLER_MAX_JOB_COUNT_ACTIVE)); |
| if (mMaxBucketJobCounts[ACTIVE_INDEX] != newActiveMaxJobCount) { |
| mMaxBucketJobCounts[ACTIVE_INDEX] = newActiveMaxJobCount; |
| changed = true; |
| } |
| int newWorkingMaxJobCount = Math.max(mMaxJobCountPerAllowedTime, |
| Math.max(MIN_BUCKET_JOB_COUNT, mConstants.QUOTA_CONTROLLER_MAX_JOB_COUNT_WORKING)); |
| if (mMaxBucketJobCounts[WORKING_INDEX] != newWorkingMaxJobCount) { |
| mMaxBucketJobCounts[WORKING_INDEX] = newWorkingMaxJobCount; |
| changed = true; |
| } |
| int newFrequentMaxJobCount = Math.max(mMaxJobCountPerAllowedTime, |
| Math.max(MIN_BUCKET_JOB_COUNT, mConstants.QUOTA_CONTROLLER_MAX_JOB_COUNT_FREQUENT)); |
| if (mMaxBucketJobCounts[FREQUENT_INDEX] != newFrequentMaxJobCount) { |
| mMaxBucketJobCounts[FREQUENT_INDEX] = newFrequentMaxJobCount; |
| changed = true; |
| } |
| int newRareMaxJobCount = Math.max(mMaxJobCountPerAllowedTime, |
| Math.max(MIN_BUCKET_JOB_COUNT, mConstants.QUOTA_CONTROLLER_MAX_JOB_COUNT_RARE)); |
| if (mMaxBucketJobCounts[RARE_INDEX] != newRareMaxJobCount) { |
| mMaxBucketJobCounts[RARE_INDEX] = newRareMaxJobCount; |
| changed = true; |
| } |
| |
| if (changed) { |
| // Update job bookkeeping out of band. |
| BackgroundThread.getHandler().post(() -> { |
| synchronized (mLock) { |
| maybeUpdateAllConstraintsLocked(); |
| } |
| }); |
| } |
| } |
| |
| @Override |
| public void onAppRemovedLocked(String packageName, int uid) { |
| if (packageName == null) { |
| Slog.wtf(TAG, "Told app removed but given null package name."); |
| return; |
| } |
| final int userId = UserHandle.getUserId(uid); |
| mTrackedJobs.delete(userId, packageName); |
| Timer timer = mPkgTimers.get(userId, packageName); |
| if (timer != null) { |
| if (timer.isActive()) { |
| Slog.wtf(TAG, "onAppRemovedLocked called before Timer turned off."); |
| timer.dropEverything(); |
| } |
| mPkgTimers.delete(userId, packageName); |
| } |
| mTimingSessions.delete(userId, packageName); |
| QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, packageName); |
| if (alarmListener != null) { |
| mAlarmManager.cancel(alarmListener); |
| mInQuotaAlarmListeners.delete(userId, packageName); |
| } |
| mExecutionStatsCache.delete(userId, packageName); |
| mForegroundUids.delete(uid); |
| } |
| |
| @Override |
| public void onUserRemovedLocked(int userId) { |
| mTrackedJobs.delete(userId); |
| mPkgTimers.delete(userId); |
| mTimingSessions.delete(userId); |
| mInQuotaAlarmListeners.delete(userId); |
| mExecutionStatsCache.delete(userId); |
| } |
| |
| private boolean isUidInForeground(int uid) { |
| if (UserHandle.isCore(uid)) { |
| return true; |
| } |
| synchronized (mLock) { |
| return mForegroundUids.get(uid); |
| } |
| } |
| |
| /** @return true if the job was started while the app was in the TOP state. */ |
| private boolean isTopStartedJob(@NonNull final JobStatus jobStatus) { |
| return mTopStartedJobs.contains(jobStatus); |
| } |
| |
| /** |
| * 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); |
| Timer timer = mPkgTimers.get(jobStatus.getSourceUserId(), jobStatus.getSourcePackageName()); |
| // A job is within quota if one of the following is true: |
| // 1. it was started while the app was in the TOP state |
| // 2. the app is currently in the foreground |
| // 3. the app overall is within its quota |
| return isTopStartedJob(jobStatus) |
| || isUidInForeground(jobStatus.getSourceUid()) |
| || isWithinQuotaLocked( |
| jobStatus.getSourceUserId(), jobStatus.getSourcePackageName(), standbyBucket); |
| } |
| |
| @VisibleForTesting |
| boolean isWithinQuotaLocked(final int userId, @NonNull final String packageName, |
| final int standbyBucket) { |
| if (standbyBucket == NEVER_INDEX) return false; |
| // 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. |
| if (mChargeTracker.isCharging() || mInParole) { |
| return true; |
| } |
| |
| return getRemainingExecutionTimeLocked(userId, packageName, standbyBucket) > 0 |
| && isUnderJobCountQuotaLocked(userId, packageName, standbyBucket); |
| } |
| |
| private boolean isUnderJobCountQuotaLocked(final int userId, @NonNull final String packageName, |
| final int standbyBucket) { |
| ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket, false); |
| return isUnderJobCountQuotaLocked(stats, standbyBucket); |
| } |
| |
| private boolean isUnderJobCountQuotaLocked(@NonNull ExecutionStats stats, |
| final int standbyBucket) { |
| final long now = sElapsedRealtimeClock.millis(); |
| final boolean isUnderAllowedTimeQuota = |
| (stats.jobCountExpirationTimeElapsed <= now |
| || stats.jobCountInAllowedTime < mMaxJobCountPerAllowedTime); |
| return isUnderAllowedTimeQuota |
| && (stats.bgJobCountInWindow < mMaxBucketJobCounts[standbyBucket]); |
| } |
| |
| @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 ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket); |
| return Math.min(mAllowedTimePerPeriodMs - stats.executionTimeInWindowMs, |
| mMaxExecutionTimeMs - stats.executionTimeInMaxPeriodMs); |
| } |
| |
| /** |
| * Returns the amount of time, in milliseconds, until the package would have reached its |
| * duration quota, assuming it has a job counting towards its quota the entire time. This takes |
| * into account any {@link TimingSession}s that may roll out of the window as the job is |
| * running. |
| */ |
| @VisibleForTesting |
| long getTimeUntilQuotaConsumedLocked(final int userId, @NonNull final String packageName) { |
| final long nowElapsed = sElapsedRealtimeClock.millis(); |
| final int standbyBucket = JobSchedulerService.standbyBucketForPackage( |
| packageName, userId, nowElapsed); |
| if (standbyBucket == NEVER_INDEX) { |
| return 0; |
| } |
| List<TimingSession> sessions = mTimingSessions.get(userId, packageName); |
| if (sessions == null || sessions.size() == 0) { |
| return mAllowedTimePerPeriodMs; |
| } |
| |
| final ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket); |
| final long startWindowElapsed = nowElapsed - stats.windowSizeMs; |
| final long startMaxElapsed = nowElapsed - MAX_PERIOD_MS; |
| final long allowedTimeRemainingMs = mAllowedTimePerPeriodMs - stats.executionTimeInWindowMs; |
| final long maxExecutionTimeRemainingMs = |
| mMaxExecutionTimeMs - stats.executionTimeInMaxPeriodMs; |
| |
| // Regular ACTIVE case. Since the bucket size equals the allowed time, the app jobs can |
| // essentially run until they reach the maximum limit. |
| if (stats.windowSizeMs == mAllowedTimePerPeriodMs) { |
| return calculateTimeUntilQuotaConsumedLocked( |
| sessions, startMaxElapsed, maxExecutionTimeRemainingMs); |
| } |
| |
| // Need to check both max time and period time in case one is less than the other. |
| // For example, max time remaining could be less than bucket time remaining, but sessions |
| // contributing to the max time remaining could phase out enough that we'd want to use the |
| // bucket value. |
| return Math.min( |
| calculateTimeUntilQuotaConsumedLocked( |
| sessions, startMaxElapsed, maxExecutionTimeRemainingMs), |
| calculateTimeUntilQuotaConsumedLocked( |
| sessions, startWindowElapsed, allowedTimeRemainingMs)); |
| } |
| |
| /** |
| * Calculates how much time it will take, in milliseconds, until the quota is fully consumed. |
| * |
| * @param windowStartElapsed The start of the window, in the elapsed realtime timebase. |
| * @param deadSpaceMs How much time can be allowed to count towards the quota |
| */ |
| private long calculateTimeUntilQuotaConsumedLocked(@NonNull List<TimingSession> sessions, |
| final long windowStartElapsed, long deadSpaceMs) { |
| long timeUntilQuotaConsumedMs = 0; |
| long start = windowStartElapsed; |
| for (int i = 0; i < sessions.size(); ++i) { |
| TimingSession session = sessions.get(i); |
| |
| if (session.endTimeElapsed < windowStartElapsed) { |
| // Outside of window. Ignore. |
| continue; |
| } else if (session.startTimeElapsed <= windowStartElapsed) { |
| // Overlapping session. Can extend time by portion of session in window. |
| timeUntilQuotaConsumedMs += session.endTimeElapsed - windowStartElapsed; |
| start = session.endTimeElapsed; |
| } else { |
| // Completely within the window. Can only consider if there's enough dead space |
| // to get to the start of the session. |
| long diff = session.startTimeElapsed - start; |
| if (diff > deadSpaceMs) { |
| break; |
| } |
| timeUntilQuotaConsumedMs += diff |
| + (session.endTimeElapsed - session.startTimeElapsed); |
| deadSpaceMs -= diff; |
| start = session.endTimeElapsed; |
| } |
| } |
| // Will be non-zero if the loop didn't look at any sessions. |
| timeUntilQuotaConsumedMs += deadSpaceMs; |
| if (timeUntilQuotaConsumedMs > mMaxExecutionTimeMs) { |
| Slog.wtf(TAG, "Calculated quota consumed time too high: " + timeUntilQuotaConsumedMs); |
| } |
| return timeUntilQuotaConsumedMs; |
| } |
| |
| /** Returns the execution stats of the app in the most recent window. */ |
| @VisibleForTesting |
| @NonNull |
| ExecutionStats getExecutionStatsLocked(final int userId, @NonNull final String packageName, |
| final int standbyBucket) { |
| return getExecutionStatsLocked(userId, packageName, standbyBucket, true); |
| } |
| |
| @NonNull |
| private ExecutionStats getExecutionStatsLocked(final int userId, |
| @NonNull final String packageName, final int standbyBucket, |
| final boolean refreshStatsIfOld) { |
| if (standbyBucket == NEVER_INDEX) { |
| Slog.wtf(TAG, "getExecutionStatsLocked called for a NEVER app."); |
| return new ExecutionStats(); |
| } |
| ExecutionStats[] appStats = mExecutionStatsCache.get(userId, packageName); |
| if (appStats == null) { |
| appStats = new ExecutionStats[mBucketPeriodsMs.length]; |
| mExecutionStatsCache.add(userId, packageName, appStats); |
| } |
| ExecutionStats stats = appStats[standbyBucket]; |
| if (stats == null) { |
| stats = new ExecutionStats(); |
| appStats[standbyBucket] = stats; |
| } |
| if (refreshStatsIfOld) { |
| final long bucketWindowSizeMs = mBucketPeriodsMs[standbyBucket]; |
| Timer timer = mPkgTimers.get(userId, packageName); |
| if ((timer != null && timer.isActive()) |
| || stats.expirationTimeElapsed <= sElapsedRealtimeClock.millis() |
| || stats.windowSizeMs != bucketWindowSizeMs) { |
| // The stats are no longer valid. |
| stats.windowSizeMs = bucketWindowSizeMs; |
| updateExecutionStatsLocked(userId, packageName, stats); |
| } |
| } |
| |
| return stats; |
| } |
| |
| @VisibleForTesting |
| void updateExecutionStatsLocked(final int userId, @NonNull final String packageName, |
| @NonNull ExecutionStats stats) { |
| stats.executionTimeInWindowMs = 0; |
| stats.bgJobCountInWindow = 0; |
| stats.executionTimeInMaxPeriodMs = 0; |
| stats.bgJobCountInMaxPeriod = 0; |
| stats.quotaCutoffTimeElapsed = 0; |
| |
| Timer timer = mPkgTimers.get(userId, packageName); |
| final long nowElapsed = sElapsedRealtimeClock.millis(); |
| stats.expirationTimeElapsed = nowElapsed + MAX_PERIOD_MS; |
| if (timer != null && timer.isActive()) { |
| stats.executionTimeInWindowMs = |
| stats.executionTimeInMaxPeriodMs = timer.getCurrentDuration(nowElapsed); |
| stats.bgJobCountInWindow = stats.bgJobCountInMaxPeriod = timer.getBgJobCount(); |
| // If the timer is active, the value will be stale at the next method call, so |
| // invalidate now. |
| stats.expirationTimeElapsed = nowElapsed; |
| if (stats.executionTimeInWindowMs >= mAllowedTimeIntoQuotaMs) { |
| stats.quotaCutoffTimeElapsed = Math.max(stats.quotaCutoffTimeElapsed, |
| nowElapsed - mAllowedTimeIntoQuotaMs); |
| } |
| if (stats.executionTimeInMaxPeriodMs >= mMaxExecutionTimeIntoQuotaMs) { |
| stats.quotaCutoffTimeElapsed = Math.max(stats.quotaCutoffTimeElapsed, |
| nowElapsed - mMaxExecutionTimeIntoQuotaMs); |
| } |
| } |
| |
| List<TimingSession> sessions = mTimingSessions.get(userId, packageName); |
| if (sessions == null || sessions.size() == 0) { |
| return; |
| } |
| |
| final long startWindowElapsed = nowElapsed - stats.windowSizeMs; |
| final long startMaxElapsed = nowElapsed - MAX_PERIOD_MS; |
| // The minimum time between the start time and the beginning of the sessions that were |
| // looked at --> how much time the stats will be valid for. |
| long emptyTimeMs = Long.MAX_VALUE; |
| // Sessions are non-overlapping and in order of occurrence, so iterating backwards will get |
| // the most recent ones. |
| for (int i = sessions.size() - 1; i >= 0; --i) { |
| TimingSession session = sessions.get(i); |
| |
| // Window management. |
| if (startWindowElapsed < session.startTimeElapsed) { |
| stats.executionTimeInWindowMs += session.endTimeElapsed - session.startTimeElapsed; |
| stats.bgJobCountInWindow += session.bgJobCount; |
| emptyTimeMs = Math.min(emptyTimeMs, session.startTimeElapsed - startWindowElapsed); |
| if (stats.executionTimeInWindowMs >= mAllowedTimeIntoQuotaMs) { |
| stats.quotaCutoffTimeElapsed = Math.max(stats.quotaCutoffTimeElapsed, |
| session.startTimeElapsed + stats.executionTimeInWindowMs |
| - mAllowedTimeIntoQuotaMs); |
| } |
| } else if (startWindowElapsed < session.endTimeElapsed) { |
| // The session started before the window but ended within the window. Only include |
| // the portion that was within the window. |
| stats.executionTimeInWindowMs += session.endTimeElapsed - startWindowElapsed; |
| stats.bgJobCountInWindow += session.bgJobCount; |
| emptyTimeMs = 0; |
| if (stats.executionTimeInWindowMs >= mAllowedTimeIntoQuotaMs) { |
| stats.quotaCutoffTimeElapsed = Math.max(stats.quotaCutoffTimeElapsed, |
| startWindowElapsed + stats.executionTimeInWindowMs |
| - mAllowedTimeIntoQuotaMs); |
| } |
| } |
| |
| // Max period check. |
| if (startMaxElapsed < session.startTimeElapsed) { |
| stats.executionTimeInMaxPeriodMs += |
| session.endTimeElapsed - session.startTimeElapsed; |
| stats.bgJobCountInMaxPeriod += session.bgJobCount; |
| emptyTimeMs = Math.min(emptyTimeMs, session.startTimeElapsed - startMaxElapsed); |
| if (stats.executionTimeInMaxPeriodMs >= mMaxExecutionTimeIntoQuotaMs) { |
| stats.quotaCutoffTimeElapsed = Math.max(stats.quotaCutoffTimeElapsed, |
| session.startTimeElapsed + stats.executionTimeInMaxPeriodMs |
| - mMaxExecutionTimeIntoQuotaMs); |
| } |
| } else if (startMaxElapsed < session.endTimeElapsed) { |
| // The session started before the window but ended within the window. Only include |
| // the portion that was within the window. |
| stats.executionTimeInMaxPeriodMs += session.endTimeElapsed - startMaxElapsed; |
| stats.bgJobCountInMaxPeriod += session.bgJobCount; |
| emptyTimeMs = 0; |
| if (stats.executionTimeInMaxPeriodMs >= mMaxExecutionTimeIntoQuotaMs) { |
| stats.quotaCutoffTimeElapsed = Math.max(stats.quotaCutoffTimeElapsed, |
| startMaxElapsed + stats.executionTimeInMaxPeriodMs |
| - mMaxExecutionTimeIntoQuotaMs); |
| } |
| } else { |
| // This session ended before the window. No point in going any further. |
| break; |
| } |
| } |
| stats.expirationTimeElapsed = nowElapsed + emptyTimeMs; |
| } |
| |
| private void invalidateAllExecutionStatsLocked(final int userId, |
| @NonNull final String packageName) { |
| ExecutionStats[] appStats = mExecutionStatsCache.get(userId, packageName); |
| if (appStats != null) { |
| final long nowElapsed = sElapsedRealtimeClock.millis(); |
| for (int i = 0; i < appStats.length; ++i) { |
| ExecutionStats stats = appStats[i]; |
| if (stats != null) { |
| stats.expirationTimeElapsed = nowElapsed; |
| } |
| } |
| } |
| } |
| |
| @VisibleForTesting |
| void incrementJobCount(final int userId, @NonNull final String packageName, int count) { |
| final long now = sElapsedRealtimeClock.millis(); |
| ExecutionStats[] appStats = mExecutionStatsCache.get(userId, packageName); |
| if (appStats == null) { |
| appStats = new ExecutionStats[mBucketPeriodsMs.length]; |
| mExecutionStatsCache.add(userId, packageName, appStats); |
| } |
| for (int i = 0; i < appStats.length; ++i) { |
| ExecutionStats stats = appStats[i]; |
| if (stats == null) { |
| stats = new ExecutionStats(); |
| appStats[i] = stats; |
| } |
| if (stats.jobCountExpirationTimeElapsed <= now) { |
| stats.jobCountExpirationTimeElapsed = now + mAllowedTimePerPeriodMs; |
| stats.jobCountInAllowedTime = 0; |
| } |
| stats.jobCountInAllowedTime += count; |
| } |
| } |
| |
| @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); |
| // Adding a new session means that the current stats are now incorrect. |
| invalidateAllExecutionStatsLocked(userId, packageName); |
| |
| 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.onStateChanged(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 (isTopStartedJob(js)) { |
| // Job was started while the app was in the TOP state so we should allow it to |
| // finish. |
| changed |= js.setQuotaConstraintSatisfied(true); |
| } else if (realStandbyBucket != ACTIVE_INDEX |
| && realStandbyBucket == getEffectiveStandbyBucket(js)) { |
| // An app in the ACTIVE bucket may be out of quota while the job could be in quota |
| // for some reason. Therefore, avoid setting the real value here and check each job |
| // individually. |
| 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 && alarmListener.isWaiting()) { |
| 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; |
| } |
| |
| private class UidConstraintUpdater implements Consumer<JobStatus> { |
| private final UserPackageMap<Integer> mToScheduleStartAlarms = new UserPackageMap<>(); |
| public boolean wasJobChanged; |
| |
| @Override |
| public void accept(JobStatus jobStatus) { |
| wasJobChanged |= jobStatus.setQuotaConstraintSatisfied(isWithinQuotaLocked(jobStatus)); |
| final int userId = jobStatus.getSourceUserId(); |
| final String packageName = jobStatus.getSourcePackageName(); |
| final int realStandbyBucket = jobStatus.getStandbyBucket(); |
| if (isWithinQuotaLocked(userId, packageName, realStandbyBucket)) { |
| QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, packageName); |
| if (alarmListener != null && alarmListener.isWaiting()) { |
| mAlarmManager.cancel(alarmListener); |
| // Set the trigger time to 0 so that the alarm doesn't think it's still waiting. |
| alarmListener.setTriggerTime(0); |
| } |
| } else { |
| mToScheduleStartAlarms.add(userId, packageName, realStandbyBucket); |
| } |
| } |
| |
| void postProcess() { |
| for (int u = 0; u < mToScheduleStartAlarms.numUsers(); ++u) { |
| final int userId = mToScheduleStartAlarms.keyAt(u); |
| for (int p = 0; p < mToScheduleStartAlarms.numPackagesForUser(userId); ++p) { |
| final String packageName = mToScheduleStartAlarms.keyAt(u, p); |
| final int standbyBucket = mToScheduleStartAlarms.get(userId, packageName); |
| maybeScheduleStartAlarmLocked(userId, packageName, standbyBucket); |
| } |
| } |
| } |
| |
| void reset() { |
| wasJobChanged = false; |
| mToScheduleStartAlarms.clear(); |
| } |
| } |
| |
| private final UidConstraintUpdater mUpdateUidConstraints = new UidConstraintUpdater(); |
| |
| private boolean maybeUpdateConstraintForUidLocked(final int uid) { |
| mService.getJobStore().forEachJobForSourceUid(uid, mUpdateUidConstraints); |
| |
| mUpdateUidConstraints.postProcess(); |
| boolean changed = mUpdateUidConstraints.wasJobChanged; |
| mUpdateUidConstraints.reset(); |
| 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) { |
| if (standbyBucket == NEVER_INDEX) { |
| return; |
| } |
| |
| final String pkgString = string(userId, packageName); |
| ExecutionStats stats = getExecutionStatsLocked(userId, packageName, standbyBucket); |
| final boolean isUnderJobCountQuota = isUnderJobCountQuotaLocked(stats, standbyBucket); |
| |
| QcAlarmListener alarmListener = mInQuotaAlarmListeners.get(userId, packageName); |
| if (stats.executionTimeInWindowMs < mAllowedTimePerPeriodMs |
| && stats.executionTimeInMaxPeriodMs < mMaxExecutionTimeMs |
| && isUnderJobCountQuota) { |
| // Already in quota. Why was this method called? |
| if (DEBUG) { |
| Slog.e(TAG, "maybeScheduleStartAlarmLocked called for " + pkgString |
| + " even though it already has " |
| + getRemainingExecutionTimeLocked(userId, packageName, standbyBucket) |
| + "ms in its quota."); |
| } |
| 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); |
| } |
| mHandler.obtainMessage(MSG_CHECK_PACKAGE, userId, 0, packageName).sendToTarget(); |
| return; |
| } |
| |
| if (alarmListener == null) { |
| alarmListener = new QcAlarmListener(userId, packageName); |
| mInQuotaAlarmListeners.add(userId, packageName, alarmListener); |
| } |
| |
| // The time this app will have quota again. |
| long inQuotaTimeElapsed = stats.quotaCutoffTimeElapsed + stats.windowSizeMs; |
| if (stats.executionTimeInMaxPeriodMs >= mMaxExecutionTimeMs) { |
| inQuotaTimeElapsed = Math.max(inQuotaTimeElapsed, |
| stats.quotaCutoffTimeElapsed + MAX_PERIOD_MS); |
| } |
| if (!isUnderJobCountQuota) { |
| inQuotaTimeElapsed = Math.max(inQuotaTimeElapsed, |
| stats.jobCountExpirationTimeElapsed + mAllowedTimePerPeriodMs); |
| } |
| // Only schedule the alarm if: |
| // 1. There isn't one currently scheduled |
| // 2. The new alarm is significantly earlier than the previous alarm (which could be the |
| // 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) { |
| 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); |
| } else if (DEBUG) { |
| Slog.d(TAG, "No need to scheduling start alarm for " + pkgString); |
| } |
| } |
| |
| 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 background jobs ran during this session. |
| public final int bgJobCount; |
| |
| private final int mHashCode; |
| |
| TimingSession(long startElapsed, long endElapsed, int bgJobCount) { |
| this.startTimeElapsed = startElapsed; |
| this.endTimeElapsed = endElapsed; |
| this.bgJobCount = bgJobCount; |
| |
| int hashCode = 0; |
| hashCode = 31 * hashCode + hashLong(startTimeElapsed); |
| hashCode = 31 * hashCode + hashLong(endTimeElapsed); |
| hashCode = 31 * hashCode + bgJobCount; |
| mHashCode = hashCode; |
| } |
| |
| @Override |
| public String toString() { |
| return "TimingSession{" + startTimeElapsed + "->" + endTimeElapsed + ", " + bgJobCount |
| + "}"; |
| } |
| |
| @Override |
| public boolean equals(Object obj) { |
| if (obj instanceof TimingSession) { |
| TimingSession other = (TimingSession) obj; |
| return startTimeElapsed == other.startTimeElapsed |
| && endTimeElapsed == other.endTimeElapsed |
| && bgJobCount == other.bgJobCount; |
| } else { |
| return false; |
| } |
| } |
| |
| @Override |
| public int hashCode() { |
| return mHashCode; |
| } |
| |
| public void dump(IndentingPrintWriter pw) { |
| pw.print(startTimeElapsed); |
| pw.print(" -> "); |
| pw.print(endTimeElapsed); |
| pw.print(" ("); |
| pw.print(endTimeElapsed - startTimeElapsed); |
| pw.print("), "); |
| pw.print(bgJobCount); |
| pw.print(" bg 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.BG_JOB_COUNT, |
| bgJobCount); |
| |
| proto.end(token); |
| } |
| } |
| |
| private final class Timer { |
| private final Package mPkg; |
| private final int mUid; |
| |
| // List of jobs currently running for this app that started when the app wasn't in the |
| // foreground. |
| private final ArraySet<JobStatus> mRunningBgJobs = new ArraySet<>(); |
| private long mStartTimeElapsed; |
| private int mBgJobCount; |
| |
| Timer(int uid, int userId, String packageName) { |
| mPkg = new Package(userId, packageName); |
| mUid = uid; |
| } |
| |
| void startTrackingJob(@NonNull JobStatus jobStatus) { |
| if (isTopStartedJob(jobStatus)) { |
| // We intentionally don't pay attention to fg state changes after a TOP job has |
| // started. |
| if (DEBUG) { |
| Slog.v(TAG, |
| "Timer ignoring " + jobStatus.toShortString() + " because isTop"); |
| } |
| return; |
| } |
| if (DEBUG) Slog.v(TAG, "Starting to track " + jobStatus.toShortString()); |
| synchronized (mLock) { |
| // Always track jobs, even when charging. |
| mRunningBgJobs.add(jobStatus); |
| if (shouldTrackLocked()) { |
| mBgJobCount++; |
| incrementJobCount(mPkg.userId, mPkg.packageName, 1); |
| if (mRunningBgJobs.size() == 1) { |
| // Started tracking the first job. |
| mStartTimeElapsed = sElapsedRealtimeClock.millis(); |
| // Starting the timer means that all cached execution stats are now |
| // incorrect. |
| invalidateAllExecutionStatsLocked(mPkg.userId, mPkg.packageName); |
| scheduleCutoff(); |
| } |
| } |
| } |
| } |
| |
| void stopTrackingJob(@NonNull JobStatus jobStatus) { |
| if (DEBUG) Slog.v(TAG, "Stopping tracking of " + jobStatus.toShortString()); |
| synchronized (mLock) { |
| if (mRunningBgJobs.size() == 0) { |
| // maybeStopTrackingJobLocked can be called when an app cancels a job, so a |
| // 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; |
| } |
| if (mRunningBgJobs.remove(jobStatus) |
| && !mChargeTracker.isCharging() && mRunningBgJobs.size() == 0) { |
| emitSessionLocked(sElapsedRealtimeClock.millis()); |
| cancelCutoff(); |
| } |
| } |
| } |
| |
| /** |
| * Stops tracking all jobs and cancels any pending alarms. This should only be called if |
| * the Timer is not going to be used anymore. |
| */ |
| void dropEverything() { |
| mRunningBgJobs.clear(); |
| cancelCutoff(); |
| } |
| |
| private void emitSessionLocked(long nowElapsed) { |
| if (mBgJobCount <= 0) { |
| // Nothing to emit. |
| return; |
| } |
| TimingSession ts = new TimingSession(mStartTimeElapsed, nowElapsed, mBgJobCount); |
| saveTimingSession(mPkg.userId, mPkg.packageName, ts); |
| mBgJobCount = 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 mBgJobCount > 0; |
| } |
| } |
| |
| boolean isRunning(JobStatus jobStatus) { |
| return mRunningBgJobs.contains(jobStatus); |
| } |
| |
| long getCurrentDuration(long nowElapsed) { |
| synchronized (mLock) { |
| return !isActive() ? 0 : nowElapsed - mStartTimeElapsed; |
| } |
| } |
| |
| int getBgJobCount() { |
| synchronized (mLock) { |
| return mBgJobCount; |
| } |
| } |
| |
| private boolean shouldTrackLocked() { |
| return !mChargeTracker.isCharging() && !mForegroundUids.get(mUid); |
| } |
| |
| void onStateChanged(long nowElapsed, boolean isQuotaFree) { |
| synchronized (mLock) { |
| if (isQuotaFree) { |
| emitSessionLocked(nowElapsed); |
| } else if (shouldTrackLocked()) { |
| // Start timing from unplug. |
| if (mRunningBgJobs.size() > 0) { |
| mStartTimeElapsed = nowElapsed; |
| // NOTE: this does have the unfortunate consequence that if the device is |
| // repeatedly plugged in and unplugged, or an app changes foreground state |
| // very frequently, the job count for a package may be artificially high. |
| mBgJobCount = mRunningBgJobs.size(); |
| incrementJobCount(mPkg.userId, mPkg.packageName, mBgJobCount); |
| // Starting the timer means that all cached execution stats are now |
| // incorrect. |
| invalidateAllExecutionStatsLocked(mPkg.userId, mPkg.packageName); |
| // Schedule cutoff since we're now actively tracking for quotas again. |
| scheduleCutoff(); |
| } |
| } |
| } |
| } |
| |
| void 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 = getTimeUntilQuotaConsumedLocked(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(mBgJobCount); |
| pw.print(" running bg jobs"); |
| pw.println(); |
| pw.increaseIndent(); |
| for (int i = 0; i < mRunningBgJobs.size(); i++) { |
| JobStatus js = mRunningBgJobs.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.BG_JOB_COUNT, mBgJobCount); |
| for (int i = 0; i < mRunningBgJobs.size(); i++) { |
| JobStatus js = mRunningBgJobs.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); |
| timeRemainingMs = getTimeUntilQuotaConsumedLocked(pkg.userId, |
| pkg.packageName); |
| 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; |
| } |
| case MSG_UID_PROCESS_STATE_CHANGED: { |
| final int uid = msg.arg1; |
| final int procState = msg.arg2; |
| final int userId = UserHandle.getUserId(uid); |
| final long nowElapsed = sElapsedRealtimeClock.millis(); |
| |
| synchronized (mLock) { |
| boolean isQuotaFree; |
| if (procState <= ActivityManager.PROCESS_STATE_FOREGROUND_SERVICE) { |
| mForegroundUids.put(uid, true); |
| isQuotaFree = true; |
| } else { |
| mForegroundUids.delete(uid); |
| isQuotaFree = false; |
| } |
| // Update Timers first. |
| final int userIndex = mPkgTimers.indexOfKey(userId); |
| if (userIndex != -1) { |
| final int numPkgs = mPkgTimers.numPackagesForUser(userId); |
| for (int p = 0; p < numPkgs; ++p) { |
| Timer t = mPkgTimers.valueAt(userIndex, p); |
| if (t != null) { |
| t.onStateChanged(nowElapsed, isQuotaFree); |
| } |
| } |
| } |
| if (maybeUpdateConstraintForUidLocked(uid)) { |
| 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 |
| int[] getBucketMaxJobCounts() { |
| return mMaxBucketJobCounts; |
| } |
| |
| @VisibleForTesting |
| @NonNull |
| long[] getBucketWindowSizes() { |
| return mBucketPeriodsMs; |
| } |
| |
| @VisibleForTesting |
| @NonNull |
| SparseBooleanArray getForegroundUids() { |
| return mForegroundUids; |
| } |
| |
| @VisibleForTesting |
| @NonNull |
| Handler getHandler() { |
| return mHandler; |
| } |
| |
| @VisibleForTesting |
| long getInQuotaBufferMs() { |
| return mQuotaBufferMs; |
| } |
| |
| @VisibleForTesting |
| long getMaxExecutionTimeMs() { |
| return mMaxExecutionTimeMs; |
| } |
| |
| @VisibleForTesting |
| int getMaxJobCountPerAllowedTime() { |
| return mMaxJobCountPerAllowedTime; |
| } |
| |
| @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(); |
| |
| pw.print("Foreground UIDs: "); |
| pw.println(mForegroundUids.toString()); |
| 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()); |
| if (mTopStartedJobs.contains(js)) { |
| pw.print(" (TOP)"); |
| } |
| 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); |
| |
| for (int i = 0; i < mForegroundUids.size(); ++i) { |
| proto.write(StateControllerProto.QuotaController.FOREGROUND_UIDS, |
| mForegroundUids.keyAt(i)); |
| } |
| |
| 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.IS_TOP_STARTED_JOB, |
| mTopStartedJobs.contains(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); |
| } |
| } |