Merge "Add CountQuotaTracker."

This commit is contained in:
Kweku Adams
2020-01-07 22:06:41 +00:00
committed by Android (Google) Code Review
3 changed files with 1653 additions and 20 deletions

View File

@@ -0,0 +1,802 @@
/*
* Copyright (C) 2019 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.utils.quota;
import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
import static com.android.server.utils.quota.Uptc.string;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.AlarmManager;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.UserHandle;
import android.util.ArrayMap;
import android.util.LongArrayQueue;
import android.util.Slog;
import android.util.TimeUtils;
import android.util.proto.ProtoOutputStream;
import android.util.quota.CountQuotaTrackerProto;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.IndentingPrintWriter;
import java.util.function.Consumer;
import java.util.function.Function;
/**
* Class that tracks whether an app has exceeded its defined count quota.
*
* Quotas are applied per userId-package-tag combination (UPTC). Tags can be null.
*
* This tracker tracks the count of instantaneous events.
*
* Limits are applied according to the category the UPTC is placed in. If a UPTC reaches its limit,
* it will be considered out of quota until it is below that limit again. A {@link Category} is a
* basic construct to apply different limits to different groups of UPTCs. For example, standby
* buckets can be a set of categories, or foreground & background could be two categories. If every
* UPTC should have the same limits applied, then only one category is needed
* ({@see Category.SINGLE_CATEGORY}).
*
* Note: all limits are enforced per category unless explicitly stated otherwise.
*
* Test: atest com.android.server.utils.quota.CountQuotaTrackerTest
*
* @hide
*/
public class CountQuotaTracker extends QuotaTracker {
private static final String TAG = CountQuotaTracker.class.getSimpleName();
private static final boolean DEBUG = false;
private static final String ALARM_TAG_CLEANUP = "*" + TAG + ".cleanup*";
@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;
/** The window size that's used when counting the number of events. */
public long windowSizeMs;
/** The maximum number of events allowed within the window size. */
public int countLimit;
/** The total number of events that occurred in the window. */
public int countInWindow;
/**
* The time after which the app will be under the category quota again. This is only valid
* if {@link #countInWindow} >= {@link #countLimit}.
*/
public long inQuotaTimeElapsed;
@Override
public String toString() {
return "expirationTime=" + expirationTimeElapsed + ", "
+ "windowSizeMs=" + windowSizeMs + ", "
+ "countLimit=" + countLimit + ", "
+ "countInWindow=" + countInWindow + ", "
+ "inQuotaTime=" + inQuotaTimeElapsed;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof ExecutionStats) {
ExecutionStats other = (ExecutionStats) obj;
return this.expirationTimeElapsed == other.expirationTimeElapsed
&& this.windowSizeMs == other.windowSizeMs
&& this.countLimit == other.countLimit
&& this.countInWindow == other.countInWindow
&& this.inQuotaTimeElapsed == other.inQuotaTimeElapsed;
}
return false;
}
@Override
public int hashCode() {
int result = 0;
result = 31 * result + Long.hashCode(expirationTimeElapsed);
result = 31 * result + Long.hashCode(windowSizeMs);
result = 31 * result + countLimit;
result = 31 * result + countInWindow;
result = 31 * result + Long.hashCode(inQuotaTimeElapsed);
return result;
}
}
/** List of times of all instantaneous events for a UPTC, in chronological order. */
// TODO(146148168): introduce a bucketized mode that's more efficient but less accurate
@GuardedBy("mLock")
private final UptcMap<LongArrayQueue> mEventTimes = new UptcMap<>();
/** Cached calculation results for each app. */
@GuardedBy("mLock")
private final UptcMap<ExecutionStats> mExecutionStatsCache = new UptcMap<>();
private final Handler mHandler;
@GuardedBy("mLock")
private long mNextCleanupTimeElapsed = 0;
@GuardedBy("mLock")
private final AlarmManager.OnAlarmListener mEventCleanupAlarmListener = () ->
CountQuotaTracker.this.mHandler.obtainMessage(MSG_CLEAN_UP_EVENTS).sendToTarget();
/** The rolling window size for each Category's count limit. */
@GuardedBy("mLock")
private final ArrayMap<Category, Long> mCategoryCountWindowSizesMs = new ArrayMap<>();
/**
* The maximum count for each Category. For each max value count in the map, the app will
* not be allowed any more events within the latest time interval of its rolling window size.
*
* @see #mCategoryCountWindowSizesMs
*/
@GuardedBy("mLock")
private final ArrayMap<Category, Integer> mMaxCategoryCounts = new ArrayMap<>();
/** The longest period a registered category applies to. */
@GuardedBy("mLock")
private long mMaxPeriodMs = 0;
/** Drop any old events. */
private static final int MSG_CLEAN_UP_EVENTS = 1;
public CountQuotaTracker(@NonNull Context context, @NonNull Categorizer categorizer) {
this(context, categorizer, new Injector());
}
@VisibleForTesting
CountQuotaTracker(@NonNull Context context, @NonNull Categorizer categorizer,
Injector injector) {
super(context, categorizer, injector);
mHandler = new CqtHandler(context.getMainLooper());
}
// Exposed API to users.
/**
* Record that an instantaneous event happened.
*
* @return true if the UPTC is within quota, false otherwise.
*/
public boolean noteEvent(int userId, @NonNull String packageName, @Nullable String tag) {
synchronized (mLock) {
if (!isEnabledLocked() || isQuotaFreeLocked(userId, packageName)) {
return true;
}
final long nowElapsed = mInjector.getElapsedRealtime();
final LongArrayQueue times = mEventTimes
.getOrCreate(userId, packageName, tag, mCreateLongArrayQueue);
times.addLast(nowElapsed);
final ExecutionStats stats = getExecutionStatsLocked(userId, packageName, tag);
stats.countInWindow++;
stats.expirationTimeElapsed = Math.min(stats.expirationTimeElapsed,
nowElapsed + stats.windowSizeMs);
if (stats.countInWindow == stats.countLimit) {
final long windowEdgeElapsed = nowElapsed - stats.windowSizeMs;
while (times.size() > 0 && times.peekFirst() < windowEdgeElapsed) {
times.removeFirst();
}
stats.inQuotaTimeElapsed = times.peekFirst() + stats.windowSizeMs;
postQuotaStatusChanged(userId, packageName, tag);
} else if (stats.countLimit > 9
&& stats.countInWindow == stats.countLimit * 4 / 5) {
// TODO: log high watermark to statsd
Slog.w(TAG, string(userId, packageName, tag)
+ " has reached 80% of it's count limit of " + stats.countLimit);
}
maybeScheduleCleanupAlarmLocked();
return isWithinQuotaLocked(stats);
}
}
/**
* Set count limit over a rolling time window for the specified category.
*
* @param category The category these limits apply to.
* @param limit The maximum event count an app can have in the rolling window. Must be
* nonnegative.
* @param timeWindowMs The rolling time window (in milliseconds) to use when checking quota
* usage. Must be at least {@value #MIN_WINDOW_SIZE_MS} and no longer than
* {@value #MAX_WINDOW_SIZE_MS}
*/
public void setCountLimit(@NonNull Category category, int limit, long timeWindowMs) {
if (limit < 0 || timeWindowMs < 0) {
throw new IllegalArgumentException("Limit and window size must be nonnegative.");
}
synchronized (mLock) {
final Integer oldLimit = mMaxCategoryCounts.put(category, limit);
final long newWindowSizeMs = Math.max(MIN_WINDOW_SIZE_MS,
Math.min(timeWindowMs, MAX_WINDOW_SIZE_MS));
final Long oldWindowSizeMs = mCategoryCountWindowSizesMs.put(category, newWindowSizeMs);
if (oldLimit != null && oldWindowSizeMs != null
&& oldLimit == limit && oldWindowSizeMs == newWindowSizeMs) {
// No change.
return;
}
mDeleteOldEventTimesFunctor.updateMaxPeriod();
mMaxPeriodMs = mDeleteOldEventTimesFunctor.mMaxPeriodMs;
invalidateAllExecutionStatsLocked();
}
scheduleQuotaCheck();
}
/**
* Gets the count limit for the specified category.
*/
public int getLimit(@NonNull Category category) {
synchronized (mLock) {
final Integer limit = mMaxCategoryCounts.get(category);
if (limit == null) {
throw new IllegalArgumentException("Limit for " + category + " not defined");
}
return limit;
}
}
/**
* Gets the count time window for the specified category.
*/
public long getWindowSizeMs(@NonNull Category category) {
synchronized (mLock) {
final Long limitMs = mCategoryCountWindowSizesMs.get(category);
if (limitMs == null) {
throw new IllegalArgumentException("Limit for " + category + " not defined");
}
return limitMs;
}
}
// Internal implementation.
@Override
@GuardedBy("mLock")
void dropEverythingLocked() {
mExecutionStatsCache.clear();
mEventTimes.clear();
}
@Override
@GuardedBy("mLock")
@NonNull
Handler getHandler() {
return mHandler;
}
@Override
@GuardedBy("mLock")
long getInQuotaTimeElapsedLocked(final int userId, @NonNull final String packageName,
@Nullable final String tag) {
return getExecutionStatsLocked(userId, packageName, tag).inQuotaTimeElapsed;
}
@Override
@GuardedBy("mLock")
void handleRemovedAppLocked(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);
mEventTimes.delete(userId, packageName);
mExecutionStatsCache.delete(userId, packageName);
}
@Override
@GuardedBy("mLock")
void handleRemovedUserLocked(int userId) {
mEventTimes.delete(userId);
mExecutionStatsCache.delete(userId);
}
@Override
@GuardedBy("mLock")
boolean isWithinQuotaLocked(final int userId, @NonNull final String packageName,
@Nullable final String tag) {
if (!isEnabledLocked()) return true;
// Quota constraint is not enforced when quota is free.
if (isQuotaFreeLocked(userId, packageName)) {
return true;
}
return isWithinQuotaLocked(getExecutionStatsLocked(userId, packageName, tag));
}
@Override
@GuardedBy("mLock")
void maybeUpdateAllQuotaStatusLocked() {
final UptcMap<Boolean> doneMap = new UptcMap<>();
mEventTimes.forEach((userId, packageName, tag, events) -> {
if (!doneMap.contains(userId, packageName, tag)) {
maybeUpdateStatusForUptcLocked(userId, packageName, tag);
doneMap.add(userId, packageName, tag, Boolean.TRUE);
}
});
}
@Override
void maybeUpdateQuotaStatus(final int userId, @NonNull final String packageName,
@Nullable final String tag) {
synchronized (mLock) {
maybeUpdateStatusForUptcLocked(userId, packageName, tag);
}
}
@Override
@GuardedBy("mLock")
void onQuotaFreeChangedLocked(boolean isFree) {
// Nothing to do here.
}
@Override
@GuardedBy("mLock")
void onQuotaFreeChangedLocked(int userId, @NonNull String packageName, boolean isFree) {
maybeUpdateStatusForPkgLocked(userId, packageName);
}
@GuardedBy("mLock")
private boolean isWithinQuotaLocked(@NonNull final ExecutionStats stats) {
return isUnderCountQuotaLocked(stats);
}
@GuardedBy("mLock")
private boolean isUnderCountQuotaLocked(@NonNull ExecutionStats stats) {
return stats.countInWindow < stats.countLimit;
}
/** Returns the execution stats of the app in the most recent window. */
@GuardedBy("mLock")
@VisibleForTesting
@NonNull
ExecutionStats getExecutionStatsLocked(final int userId, @NonNull final String packageName,
@Nullable final String tag) {
return getExecutionStatsLocked(userId, packageName, tag, true);
}
@GuardedBy("mLock")
@NonNull
private ExecutionStats getExecutionStatsLocked(final int userId,
@NonNull final String packageName, @Nullable String tag,
final boolean refreshStatsIfOld) {
final ExecutionStats stats =
mExecutionStatsCache.getOrCreate(userId, packageName, tag, mCreateExecutionStats);
if (refreshStatsIfOld) {
final Category category = mCategorizer.getCategory(userId, packageName, tag);
final long countWindowSizeMs = mCategoryCountWindowSizesMs.getOrDefault(category,
Long.MAX_VALUE);
final int countLimit = mMaxCategoryCounts.getOrDefault(category, Integer.MAX_VALUE);
if (stats.expirationTimeElapsed <= mInjector.getElapsedRealtime()
|| stats.windowSizeMs != countWindowSizeMs
|| stats.countLimit != countLimit) {
// The stats are no longer valid.
stats.windowSizeMs = countWindowSizeMs;
stats.countLimit = countLimit;
updateExecutionStatsLocked(userId, packageName, tag, stats);
}
}
return stats;
}
@GuardedBy("mLock")
@VisibleForTesting
void updateExecutionStatsLocked(final int userId, @NonNull final String packageName,
@Nullable final String tag, @NonNull ExecutionStats stats) {
stats.countInWindow = 0;
stats.inQuotaTimeElapsed = 0;
// This can be used to determine when an app will have enough quota to transition from
// out-of-quota to in-quota.
final long nowElapsed = mInjector.getElapsedRealtime();
stats.expirationTimeElapsed = nowElapsed + mMaxPeriodMs;
final LongArrayQueue events = mEventTimes.get(userId, packageName, tag);
if (events == null) {
return;
}
// The minimum time between the start time and the beginning of the events that were
// looked at --> how much time the stats will be valid for.
long emptyTimeMs = Long.MAX_VALUE - nowElapsed;
final long eventStartWindowElapsed = nowElapsed - stats.windowSizeMs;
for (int i = events.size() - 1; i >= 0; --i) {
final long eventTimeElapsed = events.get(i);
if (eventTimeElapsed < eventStartWindowElapsed) {
// This event happened before the window. No point in going any further.
break;
}
stats.countInWindow++;
emptyTimeMs = Math.min(emptyTimeMs, eventTimeElapsed - eventStartWindowElapsed);
if (stats.countInWindow >= stats.countLimit) {
stats.inQuotaTimeElapsed = Math.max(stats.inQuotaTimeElapsed,
eventTimeElapsed + stats.windowSizeMs);
}
}
stats.expirationTimeElapsed = nowElapsed + emptyTimeMs;
}
/** Invalidate ExecutionStats for all apps. */
@GuardedBy("mLock")
private void invalidateAllExecutionStatsLocked() {
final long nowElapsed = mInjector.getElapsedRealtime();
mExecutionStatsCache.forEach((appStats) -> {
if (appStats != null) {
appStats.expirationTimeElapsed = nowElapsed;
}
});
}
@GuardedBy("mLock")
private void invalidateAllExecutionStatsLocked(final int userId,
@NonNull final String packageName) {
final ArrayMap<String, ExecutionStats> appStats =
mExecutionStatsCache.get(userId, packageName);
if (appStats != null) {
final long nowElapsed = mInjector.getElapsedRealtime();
final int numStats = appStats.size();
for (int i = 0; i < numStats; ++i) {
final ExecutionStats stats = appStats.valueAt(i);
if (stats != null) {
stats.expirationTimeElapsed = nowElapsed;
}
}
}
}
@GuardedBy("mLock")
private void invalidateExecutionStatsLocked(final int userId, @NonNull final String packageName,
@Nullable String tag) {
final ExecutionStats stats = mExecutionStatsCache.get(userId, packageName, tag);
if (stats != null) {
stats.expirationTimeElapsed = mInjector.getElapsedRealtime();
}
}
private static final class EarliestEventTimeFunctor implements Consumer<LongArrayQueue> {
long earliestTimeElapsed = Long.MAX_VALUE;
@Override
public void accept(LongArrayQueue events) {
if (events != null && events.size() > 0) {
earliestTimeElapsed = Math.min(earliestTimeElapsed, events.get(0));
}
}
void reset() {
earliestTimeElapsed = Long.MAX_VALUE;
}
}
private final EarliestEventTimeFunctor mEarliestEventTimeFunctor =
new EarliestEventTimeFunctor();
/** Schedule a cleanup alarm if necessary and there isn't already one scheduled. */
@GuardedBy("mLock")
@VisibleForTesting
void maybeScheduleCleanupAlarmLocked() {
if (mNextCleanupTimeElapsed > mInjector.getElapsedRealtime()) {
// 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
- mInjector.getElapsedRealtime()) + "ms)");
}
return;
}
mEarliestEventTimeFunctor.reset();
mEventTimes.forEach(mEarliestEventTimeFunctor);
final long earliestEndElapsed = mEarliestEventTimeFunctor.earliestTimeElapsed;
if (earliestEndElapsed == Long.MAX_VALUE) {
// Couldn't find a good time to clean up. Maybe this was called after we deleted all
// events.
if (DEBUG) {
Slog.d(TAG, "Didn't find a time to schedule cleanup");
}
return;
}
// Need to keep events for all apps up to the max period, regardless of their current
// category.
long nextCleanupElapsed = earliestEndElapsed + mMaxPeriodMs;
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;
scheduleAlarm(AlarmManager.ELAPSED_REALTIME, nextCleanupElapsed, ALARM_TAG_CLEANUP,
mEventCleanupAlarmListener);
if (DEBUG) {
Slog.d(TAG, "Scheduled next cleanup for " + mNextCleanupTimeElapsed);
}
}
@GuardedBy("mLock")
private boolean maybeUpdateStatusForPkgLocked(final int userId,
@NonNull final String packageName) {
final UptcMap<Boolean> done = new UptcMap<>();
if (!mEventTimes.contains(userId, packageName)) {
return false;
}
final ArrayMap<String, LongArrayQueue> events = mEventTimes.get(userId, packageName);
if (events == null) {
Slog.wtf(TAG,
"Events map was null even though mEventTimes said it contained "
+ string(userId, packageName, null));
return false;
}
// Lambdas can't interact with non-final outer variables.
final boolean[] changed = {false};
events.forEach((tag, eventList) -> {
if (!done.contains(userId, packageName, tag)) {
changed[0] |= maybeUpdateStatusForUptcLocked(userId, packageName, tag);
done.add(userId, packageName, tag, Boolean.TRUE);
}
});
return changed[0];
}
/**
* Posts that the quota status for the UPTC has changed if it has changed. Avoid calling if
* there are no {@link QuotaChangeListener}s registered as the work done will be useless.
*
* @return true if the in/out quota status changed
*/
@GuardedBy("mLock")
private boolean maybeUpdateStatusForUptcLocked(final int userId,
@NonNull final String packageName, @Nullable final String tag) {
final boolean oldInQuota = isWithinQuotaLocked(
getExecutionStatsLocked(userId, packageName, tag, false));
final boolean newInQuota;
if (!isEnabledLocked() || isQuotaFreeLocked(userId, packageName)) {
newInQuota = true;
} else {
newInQuota = isWithinQuotaLocked(
getExecutionStatsLocked(userId, packageName, tag, true));
}
if (!newInQuota) {
maybeScheduleStartAlarmLocked(userId, packageName, tag);
} else {
cancelScheduledStartAlarmLocked(userId, packageName, tag);
}
if (oldInQuota != newInQuota) {
if (DEBUG) {
Slog.d(TAG,
"Quota status changed from " + oldInQuota + " to " + newInQuota + " for "
+ string(userId, packageName, tag));
}
postQuotaStatusChanged(userId, packageName, tag);
return true;
}
return false;
}
private final class DeleteEventTimesFunctor implements Consumer<LongArrayQueue> {
private long mMaxPeriodMs;
@Override
public void accept(LongArrayQueue times) {
if (times != null) {
// Remove everything older than mMaxPeriodMs time ago.
while (times.size() > 0
&& times.peekFirst() <= mInjector.getElapsedRealtime() - mMaxPeriodMs) {
times.removeFirst();
}
}
}
private void updateMaxPeriod() {
long maxPeriodMs = 0;
for (int i = mCategoryCountWindowSizesMs.size() - 1; i >= 0; --i) {
maxPeriodMs = Long.max(maxPeriodMs, mCategoryCountWindowSizesMs.valueAt(i));
}
mMaxPeriodMs = maxPeriodMs;
}
}
private final DeleteEventTimesFunctor mDeleteOldEventTimesFunctor =
new DeleteEventTimesFunctor();
@GuardedBy("mLock")
@VisibleForTesting
void deleteObsoleteEventsLocked() {
mEventTimes.forEach(mDeleteOldEventTimesFunctor);
}
private class CqtHandler extends Handler {
CqtHandler(Looper looper) {
super(looper);
}
@Override
public void handleMessage(Message msg) {
synchronized (mLock) {
switch (msg.what) {
case MSG_CLEAN_UP_EVENTS: {
if (DEBUG) {
Slog.d(TAG, "Cleaning up events.");
}
deleteObsoleteEventsLocked();
maybeScheduleCleanupAlarmLocked();
break;
}
}
}
}
}
private Function<Void, LongArrayQueue> mCreateLongArrayQueue = aVoid -> new LongArrayQueue();
private Function<Void, ExecutionStats> mCreateExecutionStats = aVoid -> new ExecutionStats();
//////////////////////// TESTING HELPERS /////////////////////////////
@VisibleForTesting
@Nullable
LongArrayQueue getEvents(int userId, String packageName, String tag) {
return mEventTimes.get(userId, packageName, tag);
}
//////////////////////////// DATA DUMP //////////////////////////////
/** Dump state in text format. */
public void dump(final IndentingPrintWriter pw) {
pw.print(TAG);
pw.println(":");
pw.increaseIndent();
synchronized (mLock) {
super.dump(pw);
pw.println();
pw.println("Instantaneous events:");
pw.increaseIndent();
mEventTimes.forEach((userId, pkgName, tag, events) -> {
if (events.size() > 0) {
pw.print(string(userId, pkgName, tag));
pw.println(":");
pw.increaseIndent();
pw.print(events.get(0));
for (int i = 1; i < events.size(); ++i) {
pw.print(", ");
pw.print(events.get(i));
}
pw.decreaseIndent();
pw.println();
}
});
pw.decreaseIndent();
pw.println();
pw.println("Cached execution stats:");
pw.increaseIndent();
mExecutionStatsCache.forEach((userId, pkgName, tag, stats) -> {
if (stats != null) {
pw.print(string(userId, pkgName, tag));
pw.println(":");
pw.increaseIndent();
pw.println(stats);
pw.decreaseIndent();
}
});
pw.decreaseIndent();
pw.println();
pw.println("Limits:");
pw.increaseIndent();
final int numCategories = mCategoryCountWindowSizesMs.size();
for (int i = 0; i < numCategories; ++i) {
final Category category = mCategoryCountWindowSizesMs.keyAt(i);
pw.print(category);
pw.print(": ");
pw.print(mMaxCategoryCounts.get(category));
pw.print(" events in ");
pw.println(TimeUtils.formatDuration(mCategoryCountWindowSizesMs.get(category)));
}
pw.decreaseIndent();
}
pw.decreaseIndent();
}
/**
* Dump state to proto.
*
* @param proto The ProtoOutputStream to write to.
* @param fieldId The field ID of the {@link CountQuotaTrackerProto}.
*/
public void dump(ProtoOutputStream proto, long fieldId) {
final long token = proto.start(fieldId);
synchronized (mLock) {
super.dump(proto, CountQuotaTrackerProto.BASE_QUOTA_DATA);
for (int i = 0; i < mCategoryCountWindowSizesMs.size(); ++i) {
final Category category = mCategoryCountWindowSizesMs.keyAt(i);
final long clToken = proto.start(CountQuotaTrackerProto.COUNT_LIMIT);
category.dumpDebug(proto, CountQuotaTrackerProto.CountLimit.CATEGORY);
proto.write(CountQuotaTrackerProto.CountLimit.LIMIT,
mMaxCategoryCounts.get(category));
proto.write(CountQuotaTrackerProto.CountLimit.WINDOW_SIZE_MS,
mCategoryCountWindowSizesMs.get(category));
proto.end(clToken);
}
mExecutionStatsCache.forEach((userId, pkgName, tag, stats) -> {
final boolean isQuotaFree = isIndividualQuotaFreeLocked(userId, pkgName);
final long usToken = proto.start(CountQuotaTrackerProto.UPTC_STATS);
(new Uptc(userId, pkgName, tag))
.dumpDebug(proto, CountQuotaTrackerProto.UptcStats.UPTC);
proto.write(CountQuotaTrackerProto.UptcStats.IS_QUOTA_FREE, isQuotaFree);
final LongArrayQueue events = mEventTimes.get(userId, pkgName, tag);
if (events != null) {
for (int j = events.size() - 1; j >= 0; --j) {
final long eToken = proto.start(CountQuotaTrackerProto.UptcStats.EVENTS);
proto.write(CountQuotaTrackerProto.Event.TIMESTAMP_ELAPSED, events.get(j));
proto.end(eToken);
}
}
final long statsToken = proto.start(
CountQuotaTrackerProto.UptcStats.EXECUTION_STATS);
proto.write(
CountQuotaTrackerProto.ExecutionStats.EXPIRATION_TIME_ELAPSED,
stats.expirationTimeElapsed);
proto.write(
CountQuotaTrackerProto.ExecutionStats.WINDOW_SIZE_MS,
stats.windowSizeMs);
proto.write(CountQuotaTrackerProto.ExecutionStats.COUNT_LIMIT, stats.countLimit);
proto.write(
CountQuotaTrackerProto.ExecutionStats.COUNT_IN_WINDOW,
stats.countInWindow);
proto.write(
CountQuotaTrackerProto.ExecutionStats.IN_QUOTA_TIME_ELAPSED,
stats.inQuotaTimeElapsed);
proto.end(statsToken);
proto.end(usToken);
});
proto.end(token);
}
}
}

View File

@@ -112,37 +112,20 @@ class UptcMap<T> {
return data.get(tag);
}
/**
* Returns the index for which {@link #getUserIdAtIndex(int)} would return the specified userId,
* or a negative number if the specified userId is not mapped.
*/
public int indexOfUserId(int userId) {
return mData.indexOfKey(userId);
}
/**
* Returns the index for which {@link #getPackageNameAtIndex(int, int)} would return the
* specified userId, or a negative number if the specified userId and packageName are not mapped
* together.
*/
public int indexOfUserIdAndPackage(int userId, @NonNull String packageName) {
return mData.indexOfKey(userId, packageName);
}
/** Returns the userId at the given index. */
public int getUserIdAtIndex(int index) {
private int getUserIdAtIndex(int index) {
return mData.keyAt(index);
}
/** Returns the package name at the given index. */
@NonNull
public String getPackageNameAtIndex(int userIndex, int packageIndex) {
private String getPackageNameAtIndex(int userIndex, int packageIndex) {
return mData.keyAt(userIndex, packageIndex);
}
/** Returns the tag at the given index. */
@NonNull
public String getTagAtIndex(int userIndex, int packageIndex, int tagIndex) {
private String getTagAtIndex(int userIndex, int packageIndex, int tagIndex) {
// This structure never inserts a null ArrayMap, so if the indices are valid, valueAt()
// won't return null.
return mData.valueAt(userIndex, packageIndex).keyAt(tagIndex);

View File

@@ -0,0 +1,848 @@
/*
* Copyright (C) 2019 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.utils.quota;
import static com.android.dx.mockito.inline.extended.ExtendedMockito.doNothing;
import static com.android.dx.mockito.inline.extended.ExtendedMockito.inOrder;
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.utils.quota.Category.SINGLE_CATEGORY;
import static com.android.server.utils.quota.QuotaTracker.MAX_WINDOW_SIZE_MS;
import static com.android.server.utils.quota.QuotaTracker.MIN_WINDOW_SIZE_MS;
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.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.atLeastOnce;
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.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import android.os.UserHandle;
import android.util.LongArrayQueue;
import androidx.test.runner.AndroidJUnit4;
import com.android.server.LocalServices;
import com.android.server.utils.quota.CountQuotaTracker.ExecutionStats;
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;
/**
* Tests for {@link CountQuotaTracker}.
*/
@RunWith(AndroidJUnit4.class)
public class CountQuotaTrackerTest {
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 = "*CountQuotaTracker.cleanup*";
private static final String TAG_QUOTA_CHECK = "*QuotaTracker.quota_check*";
private static final String TEST_PACKAGE = "com.android.frameworks.mockingservicestests";
private static final String TEST_TAG = "testing";
private static final int TEST_UID = 10987;
private static final int TEST_USER_ID = 0;
/** A {@link Category} to represent the ACTIVE standby bucket. */
private static final Category ACTIVE_BUCKET_CATEGORY = new Category("ACTIVE");
/** A {@link Category} to represent the WORKING_SET standby bucket. */
private static final Category WORKING_SET_BUCKET_CATEGORY = new Category("WORKING_SET");
/** A {@link Category} to represent the FREQUENT standby bucket. */
private static final Category FREQUENT_BUCKET_CATEGORY = new Category("FREQUENT");
/** A {@link Category} to represent the RARE standby bucket. */
private static final Category RARE_BUCKET_CATEGORY = new Category("RARE");
private CountQuotaTracker mQuotaTracker;
private final CategorizerForTest mCategorizer = new CategorizerForTest();
private final InjectorForTest mInjector = new InjectorForTest();
private final TestQuotaChangeListener mQuotaChangeListener = new TestQuotaChangeListener();
private BroadcastReceiver mReceiver;
private MockitoSession mMockingSession;
@Mock
private AlarmManager mAlarmManager;
@Mock
private Context mContext;
static class CategorizerForTest implements Categorizer {
private Category mCategoryToUse = SINGLE_CATEGORY;
@Override
public Category getCategory(int userId,
String packageName, String tag) {
return mCategoryToUse;
}
}
private static class InjectorForTest extends QuotaTracker.Injector {
private long mElapsedTime = SystemClock.elapsedRealtime();
@Override
long getElapsedRealtime() {
return mElapsedTime;
}
@Override
boolean isAlarmManagerReady() {
return true;
}
}
private static class TestQuotaChangeListener implements QuotaChangeListener {
@Override
public void onQuotaStateChanged(int userId, String packageName, String tag) {
}
}
@Before
public void setUp() {
mMockingSession = mockitoSession()
.initMocks(this)
.strictness(Strictness.LENIENT)
.mockStatic(LocalServices.class)
.startMocking();
when(mContext.getMainLooper()).thenReturn(Looper.getMainLooper());
when(mContext.getSystemService(AlarmManager.class)).thenReturn(mAlarmManager);
// Freeze the clocks at 24 hours after this moment in time. Several tests create sessions
// in the past, and QuotaController sometimes floors values at 0, so if the test time
// causes sessions with negative timestamps, they will fail.
advanceElapsedClock(24 * HOUR_IN_MILLIS);
// Initialize real objects.
// Capture the listeners.
ArgumentCaptor<BroadcastReceiver> receiverCaptor =
ArgumentCaptor.forClass(BroadcastReceiver.class);
mQuotaTracker = new CountQuotaTracker(mContext, mCategorizer, mInjector);
mQuotaTracker.setEnabled(true);
mQuotaTracker.setQuotaFree(false);
mQuotaTracker.registerQuotaChangeListener(mQuotaChangeListener);
verify(mContext, atLeastOnce()).registerReceiverAsUser(
receiverCaptor.capture(), eq(UserHandle.ALL), any(), any(), any());
mReceiver = receiverCaptor.getValue();
}
@After
public void tearDown() {
if (mMockingSession != null) {
mMockingSession.finishMocking();
}
}
/**
* Returns true if the two {@link LongArrayQueue}s have the same size and the same elements in
* the same order.
*/
private static boolean longArrayQueueEquals(LongArrayQueue queue1, LongArrayQueue queue2) {
if (queue1 == queue2) {
return true;
} else if (queue1 == null || queue2 == null) {
return false;
}
if (queue1.size() == queue2.size()) {
for (int i = 0; i < queue1.size(); ++i) {
if (queue1.get(i) != queue2.get(i)) {
return false;
}
}
return true;
}
return false;
}
private void advanceElapsedClock(long incrementMs) {
mInjector.mElapsedTime += incrementMs;
}
private void logEvents(int count) {
logEvents(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, count);
}
private void logEvents(int userId, String pkgName, String tag, int count) {
for (int i = 0; i < count; ++i) {
mQuotaTracker.noteEvent(userId, pkgName, tag);
}
}
private void logEventAt(long timeElapsed) {
logEventAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, timeElapsed);
}
private void logEventAt(int userId, String pkgName, String tag, long timeElapsed) {
long now = mInjector.getElapsedRealtime();
mInjector.mElapsedTime = timeElapsed;
mQuotaTracker.noteEvent(userId, pkgName, tag);
mInjector.mElapsedTime = now;
}
private void logEventsAt(int userId, String pkgName, String tag, long timeElapsed, int count) {
for (int i = 0; i < count; ++i) {
logEventAt(userId, pkgName, tag, timeElapsed);
}
}
@Test
public void testDeleteObsoleteEventsLocked() {
// Count window size should only apply to event list.
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 7, 2 * HOUR_IN_MILLIS);
final long now = mInjector.getElapsedRealtime();
logEventAt(now - 6 * HOUR_IN_MILLIS);
logEventAt(now - 5 * HOUR_IN_MILLIS);
logEventAt(now - 4 * HOUR_IN_MILLIS);
logEventAt(now - 3 * HOUR_IN_MILLIS);
logEventAt(now - 2 * HOUR_IN_MILLIS);
logEventAt(now - HOUR_IN_MILLIS);
logEventAt(now - 1);
LongArrayQueue expectedEvents = new LongArrayQueue();
expectedEvents.addLast(now - HOUR_IN_MILLIS);
expectedEvents.addLast(now - 1);
mQuotaTracker.deleteObsoleteEventsLocked();
LongArrayQueue remainingEvents = mQuotaTracker.getEvents(TEST_USER_ID, TEST_PACKAGE,
TEST_TAG);
assertTrue(longArrayQueueEquals(expectedEvents, remainingEvents));
}
@Test
public void testAppRemoval() {
final long now = mInjector.getElapsedRealtime();
logEventAt(TEST_USER_ID, "com.android.test.remove", "tag1", now - (6 * HOUR_IN_MILLIS));
logEventAt(TEST_USER_ID, "com.android.test.remove", "tag2",
now - (2 * HOUR_IN_MILLIS + MINUTE_IN_MILLIS));
logEventAt(TEST_USER_ID, "com.android.test.remove", "tag3", now - (HOUR_IN_MILLIS));
// Test that another app isn't affected.
LongArrayQueue expected1 = new LongArrayQueue();
expected1.addLast(now - 10 * MINUTE_IN_MILLIS);
LongArrayQueue expected2 = new LongArrayQueue();
expected2.addLast(now - 70 * MINUTE_IN_MILLIS);
logEventAt(TEST_USER_ID, "com.android.test.stay", "tag1", now - 10 * MINUTE_IN_MILLIS);
logEventAt(TEST_USER_ID, "com.android.test.stay", "tag2", now - 70 * MINUTE_IN_MILLIS);
Intent removal = new Intent(Intent.ACTION_PACKAGE_FULLY_REMOVED,
Uri.fromParts("package", "com.android.test.remove", null));
removal.putExtra(Intent.EXTRA_UID, TEST_UID);
mReceiver.onReceive(mContext, removal);
assertNull(
mQuotaTracker.getEvents(TEST_USER_ID, "com.android.test.remove", "tag1"));
assertNull(
mQuotaTracker.getEvents(TEST_USER_ID, "com.android.test.remove", "tag2"));
assertNull(
mQuotaTracker.getEvents(TEST_USER_ID, "com.android.test.remove", "tag3"));
assertTrue(longArrayQueueEquals(expected1,
mQuotaTracker.getEvents(TEST_USER_ID, "com.android.test.stay", "tag1")));
assertTrue(longArrayQueueEquals(expected2,
mQuotaTracker.getEvents(TEST_USER_ID, "com.android.test.stay", "tag2")));
}
@Test
public void testUserRemoval() {
final long now = mInjector.getElapsedRealtime();
logEventAt(TEST_USER_ID, TEST_PACKAGE, "tag1", now - (6 * HOUR_IN_MILLIS));
logEventAt(TEST_USER_ID, TEST_PACKAGE, "tag2",
now - (2 * HOUR_IN_MILLIS + MINUTE_IN_MILLIS));
logEventAt(TEST_USER_ID, TEST_PACKAGE, "tag3", now - (HOUR_IN_MILLIS));
// Test that another user isn't affected.
LongArrayQueue expected = new LongArrayQueue();
expected.addLast(now - (70 * MINUTE_IN_MILLIS));
expected.addLast(now - (10 * MINUTE_IN_MILLIS));
logEventAt(10, TEST_PACKAGE, "tag4", now - (70 * MINUTE_IN_MILLIS));
logEventAt(10, TEST_PACKAGE, "tag4", now - 10 * MINUTE_IN_MILLIS);
Intent removal = new Intent(Intent.ACTION_USER_REMOVED);
removal.putExtra(Intent.EXTRA_USER_HANDLE, TEST_USER_ID);
mReceiver.onReceive(mContext, removal);
assertNull(mQuotaTracker.getEvents(TEST_USER_ID, TEST_PACKAGE, "tag1"));
assertNull(mQuotaTracker.getEvents(TEST_USER_ID, TEST_PACKAGE, "tag2"));
assertNull(mQuotaTracker.getEvents(TEST_USER_ID, TEST_PACKAGE, "tag3"));
longArrayQueueEquals(expected, mQuotaTracker.getEvents(10, TEST_PACKAGE, "tag4"));
}
@Test
public void testUpdateExecutionStatsLocked_NoTimer() {
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 3, 24 * HOUR_IN_MILLIS);
final long now = mInjector.getElapsedRealtime();
// Added in chronological order.
logEventAt(now - 4 * HOUR_IN_MILLIS);
logEventAt(now - HOUR_IN_MILLIS);
logEventAt(now - 5 * MINUTE_IN_MILLIS);
logEventAt(now - MINUTE_IN_MILLIS);
// Test an app that hasn't had any activity.
ExecutionStats expectedStats = new ExecutionStats();
ExecutionStats inputStats = new ExecutionStats();
inputStats.windowSizeMs = expectedStats.windowSizeMs = 12 * HOUR_IN_MILLIS;
inputStats.countLimit = expectedStats.countLimit = 3;
// Invalid time is now +24 hours since there are no sessions at all for the app.
expectedStats.expirationTimeElapsed = now + 24 * HOUR_IN_MILLIS;
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, "com.android.test.not.run", TEST_TAG,
inputStats);
assertEquals(expectedStats, inputStats);
// Now test app that has had activity.
inputStats.windowSizeMs = expectedStats.windowSizeMs = MINUTE_IN_MILLIS;
// Invalid time is now since there was an event exactly windowSizeMs ago.
expectedStats.expirationTimeElapsed = now;
expectedStats.countInWindow = 1;
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, inputStats);
assertEquals(expectedStats, inputStats);
inputStats.windowSizeMs = expectedStats.windowSizeMs = 3 * MINUTE_IN_MILLIS;
expectedStats.expirationTimeElapsed = now + 2 * MINUTE_IN_MILLIS;
expectedStats.countInWindow = 1;
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, inputStats);
assertEquals(expectedStats, inputStats);
inputStats.windowSizeMs = expectedStats.windowSizeMs = 4 * MINUTE_IN_MILLIS;
expectedStats.expirationTimeElapsed = now + 3 * MINUTE_IN_MILLIS;
expectedStats.countInWindow = 1;
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, inputStats);
assertEquals(expectedStats, inputStats);
inputStats.windowSizeMs = expectedStats.windowSizeMs = 49 * MINUTE_IN_MILLIS;
// Invalid time is now +44 minutes since the earliest session in the window is now-5
// minutes.
expectedStats.expirationTimeElapsed = now + 44 * MINUTE_IN_MILLIS;
expectedStats.countInWindow = 2;
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, inputStats);
assertEquals(expectedStats, inputStats);
inputStats.windowSizeMs = expectedStats.windowSizeMs = 50 * MINUTE_IN_MILLIS;
expectedStats.expirationTimeElapsed = now + 45 * MINUTE_IN_MILLIS;
expectedStats.countInWindow = 2;
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, inputStats);
assertEquals(expectedStats, inputStats);
inputStats.windowSizeMs = expectedStats.windowSizeMs = HOUR_IN_MILLIS;
// Invalid time is now since the event is at the very edge of the window
// cutoff time.
expectedStats.expirationTimeElapsed = now;
expectedStats.countInWindow = 3;
// App is at event count limit but the oldest session is at the edge of the window, so
// in quota time is now.
expectedStats.inQuotaTimeElapsed = now;
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, inputStats);
assertEquals(expectedStats, inputStats);
inputStats.windowSizeMs = expectedStats.windowSizeMs = 2 * HOUR_IN_MILLIS;
expectedStats.expirationTimeElapsed = now + HOUR_IN_MILLIS;
expectedStats.countInWindow = 3;
expectedStats.inQuotaTimeElapsed = now + HOUR_IN_MILLIS;
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, inputStats);
assertEquals(expectedStats, inputStats);
inputStats.windowSizeMs = expectedStats.windowSizeMs = 5 * HOUR_IN_MILLIS;
expectedStats.expirationTimeElapsed = now + HOUR_IN_MILLIS;
expectedStats.countInWindow = 4;
expectedStats.inQuotaTimeElapsed = now + 4 * HOUR_IN_MILLIS;
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, inputStats);
assertEquals(expectedStats, inputStats);
inputStats.windowSizeMs = expectedStats.windowSizeMs = 6 * HOUR_IN_MILLIS;
expectedStats.expirationTimeElapsed = now + 2 * HOUR_IN_MILLIS;
expectedStats.countInWindow = 4;
expectedStats.inQuotaTimeElapsed = now + 5 * HOUR_IN_MILLIS;
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, inputStats);
assertEquals(expectedStats, inputStats);
}
/**
* Tests that getExecutionStatsLocked returns the correct stats.
*/
@Test
public void testGetExecutionStatsLocked_Values() {
// The handler could cause changes to the cached stats, so prevent it from operating in
// this test.
Handler handler = mQuotaTracker.getHandler();
spyOn(handler);
doNothing().when(handler).handleMessage(any());
mQuotaTracker.setCountLimit(RARE_BUCKET_CATEGORY, 3, 24 * HOUR_IN_MILLIS);
mQuotaTracker.setCountLimit(FREQUENT_BUCKET_CATEGORY, 4, 8 * HOUR_IN_MILLIS);
mQuotaTracker.setCountLimit(WORKING_SET_BUCKET_CATEGORY, 9, 2 * HOUR_IN_MILLIS);
mQuotaTracker.setCountLimit(ACTIVE_BUCKET_CATEGORY, 10, 10 * MINUTE_IN_MILLIS);
final long now = mInjector.getElapsedRealtime();
logEventAt(now - 23 * HOUR_IN_MILLIS);
logEventAt(now - 7 * HOUR_IN_MILLIS);
logEventAt(now - 5 * HOUR_IN_MILLIS);
logEventAt(now - 2 * HOUR_IN_MILLIS);
logEventAt(now - 5 * MINUTE_IN_MILLIS);
ExecutionStats expectedStats = new ExecutionStats();
// Active
expectedStats.expirationTimeElapsed = now + 5 * MINUTE_IN_MILLIS;
expectedStats.windowSizeMs = 10 * MINUTE_IN_MILLIS;
expectedStats.countLimit = 10;
expectedStats.countInWindow = 1;
mCategorizer.mCategoryToUse = ACTIVE_BUCKET_CATEGORY;
assertEquals(expectedStats,
mQuotaTracker.getExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
// Working
expectedStats.expirationTimeElapsed = now;
expectedStats.windowSizeMs = 2 * HOUR_IN_MILLIS;
expectedStats.countLimit = 9;
expectedStats.countInWindow = 2;
mCategorizer.mCategoryToUse = WORKING_SET_BUCKET_CATEGORY;
assertEquals(expectedStats,
mQuotaTracker.getExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
// Frequent
expectedStats.expirationTimeElapsed = now + HOUR_IN_MILLIS;
expectedStats.windowSizeMs = 8 * HOUR_IN_MILLIS;
expectedStats.countLimit = 4;
expectedStats.countInWindow = 4;
expectedStats.inQuotaTimeElapsed = now + HOUR_IN_MILLIS;
mCategorizer.mCategoryToUse = FREQUENT_BUCKET_CATEGORY;
assertEquals(expectedStats,
mQuotaTracker.getExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
// Rare
expectedStats.expirationTimeElapsed = now + HOUR_IN_MILLIS;
expectedStats.windowSizeMs = 24 * HOUR_IN_MILLIS;
expectedStats.countLimit = 3;
expectedStats.countInWindow = 5;
expectedStats.inQuotaTimeElapsed = now + 19 * HOUR_IN_MILLIS;
mCategorizer.mCategoryToUse = RARE_BUCKET_CATEGORY;
assertEquals(expectedStats,
mQuotaTracker.getExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
}
/**
* Tests that getExecutionStatsLocked returns the correct stats soon after device startup.
*/
@Test
public void testGetExecutionStatsLocked_Values_BeginningOfTime() {
// Set time to 3 minutes after boot.
mInjector.mElapsedTime = 3 * MINUTE_IN_MILLIS;
logEventAt(30_000);
logEventAt(MINUTE_IN_MILLIS);
logEventAt(2 * MINUTE_IN_MILLIS);
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 10, 2 * HOUR_IN_MILLIS);
ExecutionStats expectedStats = new ExecutionStats();
expectedStats.windowSizeMs = 2 * HOUR_IN_MILLIS;
expectedStats.countLimit = 10;
expectedStats.countInWindow = 3;
expectedStats.expirationTimeElapsed = 2 * HOUR_IN_MILLIS + 30_000;
assertEquals(expectedStats,
mQuotaTracker.getExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
}
@Test
public void testisWithinQuota_GlobalQuotaFree() {
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 0, 2 * HOUR_IN_MILLIS);
mQuotaTracker.setQuotaFree(true);
assertTrue(mQuotaTracker.isWithinQuota(TEST_USER_ID, TEST_PACKAGE, null));
assertTrue(mQuotaTracker.isWithinQuota(TEST_USER_ID, "com.android.random.app", null));
}
@Test
public void testisWithinQuota_UptcQuotaFree() {
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 0, 2 * HOUR_IN_MILLIS);
mQuotaTracker.setQuotaFree(TEST_USER_ID, TEST_PACKAGE, true);
assertTrue(mQuotaTracker.isWithinQuota(TEST_USER_ID, TEST_PACKAGE, null));
assertFalse(
mQuotaTracker.isWithinQuota(TEST_USER_ID, "com.android.random.app", null));
}
@Test
public void testisWithinQuota_UnderCount() {
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 10, 2 * HOUR_IN_MILLIS);
logEvents(5);
assertTrue(mQuotaTracker.isWithinQuota(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
}
@Test
public void testisWithinQuota_OverCount() {
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 25, HOUR_IN_MILLIS);
logEvents(TEST_USER_ID, "com.android.test.spam", TEST_TAG, 30);
assertFalse(mQuotaTracker.isWithinQuota(TEST_USER_ID, "com.android.test.spam", TEST_TAG));
}
@Test
public void testisWithinQuota_EqualsCount() {
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 25, HOUR_IN_MILLIS);
logEvents(25);
assertFalse(mQuotaTracker.isWithinQuota(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
}
@Test
public void testisWithinQuota_DifferentCategories() {
mQuotaTracker.setCountLimit(RARE_BUCKET_CATEGORY, 3, 24 * HOUR_IN_MILLIS);
mQuotaTracker.setCountLimit(FREQUENT_BUCKET_CATEGORY, 4, 24 * HOUR_IN_MILLIS);
mQuotaTracker.setCountLimit(WORKING_SET_BUCKET_CATEGORY, 5, 24 * HOUR_IN_MILLIS);
mQuotaTracker.setCountLimit(ACTIVE_BUCKET_CATEGORY, 6, 24 * HOUR_IN_MILLIS);
for (int i = 0; i < 7; ++i) {
logEvents(1);
mCategorizer.mCategoryToUse = RARE_BUCKET_CATEGORY;
assertEquals("Rare has incorrect quota status with " + (i + 1) + " events",
i < 2,
mQuotaTracker.isWithinQuota(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
mCategorizer.mCategoryToUse = FREQUENT_BUCKET_CATEGORY;
assertEquals("Frequent has incorrect quota status with " + (i + 1) + " events",
i < 3,
mQuotaTracker.isWithinQuota(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
mCategorizer.mCategoryToUse = WORKING_SET_BUCKET_CATEGORY;
assertEquals("Working has incorrect quota status with " + (i + 1) + " events",
i < 4,
mQuotaTracker.isWithinQuota(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
mCategorizer.mCategoryToUse = ACTIVE_BUCKET_CATEGORY;
assertEquals("Active has incorrect quota status with " + (i + 1) + " events",
i < 5,
mQuotaTracker.isWithinQuota(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
}
}
@Test
public void testMaybeScheduleCleanupAlarmLocked() {
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 5, 24 * HOUR_IN_MILLIS);
// No sessions saved yet.
mQuotaTracker.maybeScheduleCleanupAlarmLocked();
verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_CLEANUP), any(), any());
// Test with only one timing session saved.
final long now = mInjector.getElapsedRealtime();
logEventAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, now - 6 * HOUR_IN_MILLIS);
mQuotaTracker.maybeScheduleCleanupAlarmLocked();
verify(mAlarmManager, timeout(1000).times(1))
.set(anyInt(), eq(now + 18 * HOUR_IN_MILLIS), eq(TAG_CLEANUP), any(), any());
// Test with new (more recent) timing sessions saved. AlarmManger shouldn't be called again.
logEventAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, now - 3 * HOUR_IN_MILLIS);
logEventAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, now - HOUR_IN_MILLIS);
mQuotaTracker.maybeScheduleCleanupAlarmLocked();
verify(mAlarmManager, times(1))
.set(anyInt(), eq(now + 18 * HOUR_IN_MILLIS), eq(TAG_CLEANUP), any(), any());
}
/**
* Tests that maybeScheduleStartAlarm schedules an alarm for the right time.
*/
@Test
public void testMaybeScheduleStartAlarmLocked() {
// logEvent calls maybeScheduleCleanupAlarmLocked which interferes with these tests
// because it schedules an alarm too. Prevent it from doing so.
spyOn(mQuotaTracker);
doNothing().when(mQuotaTracker).maybeScheduleCleanupAlarmLocked();
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 10, 8 * HOUR_IN_MILLIS);
// No sessions saved yet.
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
// Test with timing sessions out of window.
final long now = mInjector.getElapsedRealtime();
logEventsAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, now - 10 * HOUR_IN_MILLIS, 20);
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
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;
logEventsAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, start, 5);
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
// Add some more sessions, but still in quota.
logEventsAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, now - 3 * HOUR_IN_MILLIS, 1);
logEventsAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, now - HOUR_IN_MILLIS, 3);
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
verify(mAlarmManager, never()).set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
// Test when out of quota.
logEventsAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, now - HOUR_IN_MILLIS, 1);
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
verify(mAlarmManager, timeout(1000).times(1))
.set(anyInt(), eq(expectedAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
// Alarm already scheduled, so make sure it's not scheduled again.
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
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 category is changed. */
@Test
public void testMaybeScheduleStartAlarmLocked_CategoryChange() {
// logEvent calls maybeScheduleCleanupAlarmLocked which interferes with these tests
// because it schedules an alarm too. Prevent it from doing so.
spyOn(mQuotaTracker);
doNothing().when(mQuotaTracker).maybeScheduleCleanupAlarmLocked();
mQuotaTracker.setCountLimit(RARE_BUCKET_CATEGORY, 10, 24 * HOUR_IN_MILLIS);
mQuotaTracker.setCountLimit(FREQUENT_BUCKET_CATEGORY, 10, 8 * HOUR_IN_MILLIS);
mQuotaTracker.setCountLimit(WORKING_SET_BUCKET_CATEGORY, 10, 2 * HOUR_IN_MILLIS);
mQuotaTracker.setCountLimit(ACTIVE_BUCKET_CATEGORY, 10, 10 * MINUTE_IN_MILLIS);
final long now = mInjector.getElapsedRealtime();
// Affects rare bucket
logEventsAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, now - 12 * HOUR_IN_MILLIS, 9);
// Affects frequent and rare buckets
logEventsAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, now - 4 * HOUR_IN_MILLIS, 4);
// Affects working, frequent, and rare buckets
final long outOfQuotaTime = now - HOUR_IN_MILLIS;
logEventsAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, outOfQuotaTime, 7);
// Affects all buckets
logEventsAt(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, now - 5 * MINUTE_IN_MILLIS, 3);
InOrder inOrder = inOrder(mAlarmManager);
// Start in ACTIVE bucket.
mCategorizer.mCategoryToUse = ACTIVE_BUCKET_CATEGORY;
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
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);
mCategorizer.mCategoryToUse = WORKING_SET_BUCKET_CATEGORY;
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
inOrder.verify(mAlarmManager, timeout(1000).times(1))
.set(anyInt(), eq(expectedWorkingAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
final long expectedFrequentAlarmTime = outOfQuotaTime + (8 * HOUR_IN_MILLIS);
mCategorizer.mCategoryToUse = FREQUENT_BUCKET_CATEGORY;
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
inOrder.verify(mAlarmManager, timeout(1000).times(1))
.set(anyInt(), eq(expectedFrequentAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
final long expectedRareAlarmTime = outOfQuotaTime + (24 * HOUR_IN_MILLIS);
mCategorizer.mCategoryToUse = RARE_BUCKET_CATEGORY;
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
inOrder.verify(mAlarmManager, timeout(1000).times(1))
.set(anyInt(), eq(expectedRareAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
// And back up again.
mCategorizer.mCategoryToUse = FREQUENT_BUCKET_CATEGORY;
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
inOrder.verify(mAlarmManager, timeout(1000).times(1))
.set(anyInt(), eq(expectedFrequentAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
mCategorizer.mCategoryToUse = WORKING_SET_BUCKET_CATEGORY;
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
inOrder.verify(mAlarmManager, timeout(1000).times(1))
.set(anyInt(), eq(expectedWorkingAlarmTime), eq(TAG_QUOTA_CHECK), any(), any());
mCategorizer.mCategoryToUse = ACTIVE_BUCKET_CATEGORY;
mQuotaTracker.maybeScheduleStartAlarmLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
inOrder.verify(mAlarmManager, timeout(1000).times(1))
.cancel(any(AlarmManager.OnAlarmListener.class));
inOrder.verify(mAlarmManager, timeout(1000).times(0))
.set(anyInt(), anyLong(), eq(TAG_QUOTA_CHECK), any(), any());
}
@Test
public void testConstantsUpdating_ValidValues() {
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 0, 60_000);
assertEquals(0, mQuotaTracker.getLimit(SINGLE_CATEGORY));
assertEquals(60_000, mQuotaTracker.getWindowSizeMs(SINGLE_CATEGORY));
}
@Test
public void testConstantsUpdating_InvalidValues() {
// Test negatives.
try {
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, -1, 5000);
fail("Negative count limit didn't throw an exception");
} catch (IllegalArgumentException e) {
// Success
}
try {
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 1, -1);
fail("Negative count window size didn't throw an exception");
} catch (IllegalArgumentException e) {
// Success
}
// Test window sizes too low.
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 0, 1);
assertEquals(MIN_WINDOW_SIZE_MS, mQuotaTracker.getWindowSizeMs(SINGLE_CATEGORY));
// Test window sizes too high.
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 0, 365 * 24 * HOUR_IN_MILLIS);
assertEquals(MAX_WINDOW_SIZE_MS, mQuotaTracker.getWindowSizeMs(SINGLE_CATEGORY));
}
/** Tests that events aren't counted when global quota is free. */
@Test
public void testLogEvent_GlobalQuotaFree() {
mQuotaTracker.setQuotaFree(true);
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 10, 2 * HOUR_IN_MILLIS);
ExecutionStats stats =
mQuotaTracker.getExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
assertEquals(0, stats.countInWindow);
for (int i = 0; i < 10; ++i) {
mQuotaTracker.noteEvent(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
advanceElapsedClock(10 * SECOND_IN_MILLIS);
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, stats);
assertEquals(0, stats.countInWindow);
}
}
/**
* Tests that events are counted when global quota is not free.
*/
@Test
public void testLogEvent_GlobalQuotaNotFree() {
mQuotaTracker.setQuotaFree(false);
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 10, 2 * HOUR_IN_MILLIS);
ExecutionStats stats =
mQuotaTracker.getExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
assertEquals(0, stats.countInWindow);
for (int i = 0; i < 10; ++i) {
mQuotaTracker.noteEvent(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
advanceElapsedClock(10 * SECOND_IN_MILLIS);
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, stats);
assertEquals(i + 1, stats.countInWindow);
}
}
/** Tests that events aren't counted when the uptc quota is free. */
@Test
public void testLogEvent_UptcQuotaFree() {
mQuotaTracker.setQuotaFree(TEST_USER_ID, TEST_PACKAGE, true);
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 10, 2 * HOUR_IN_MILLIS);
ExecutionStats stats =
mQuotaTracker.getExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
assertEquals(0, stats.countInWindow);
for (int i = 0; i < 10; ++i) {
mQuotaTracker.noteEvent(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
advanceElapsedClock(10 * SECOND_IN_MILLIS);
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, stats);
assertEquals(0, stats.countInWindow);
}
}
/**
* Tests that events are counted when UPTC quota is not free.
*/
@Test
public void testLogEvent_UptcQuotaNotFree() {
mQuotaTracker.setQuotaFree(TEST_USER_ID, TEST_PACKAGE, false);
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 10, 2 * HOUR_IN_MILLIS);
ExecutionStats stats =
mQuotaTracker.getExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
assertEquals(0, stats.countInWindow);
for (int i = 0; i < 10; ++i) {
mQuotaTracker.noteEvent(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
advanceElapsedClock(10 * SECOND_IN_MILLIS);
mQuotaTracker.updateExecutionStatsLocked(TEST_USER_ID, TEST_PACKAGE, TEST_TAG, stats);
assertEquals(i + 1, stats.countInWindow);
}
}
/**
* Tests that QuotaChangeListeners are notified when a UPTC reaches its count quota.
*/
@Test
public void testTracking_OutOfQuota() {
spyOn(mQuotaChangeListener);
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 10, 2 * HOUR_IN_MILLIS);
logEvents(9);
mQuotaTracker.noteEvent(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
// Wait for some extra time to allow for processing.
verify(mQuotaChangeListener, timeout(3 * SECOND_IN_MILLIS).times(1))
.onQuotaStateChanged(eq(TEST_USER_ID), eq(TEST_PACKAGE), eq(TEST_TAG));
assertFalse(mQuotaTracker.isWithinQuota(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
}
/**
* Tests that QuotaChangeListeners are not incorrectly notified after a UPTC event is logged
* quota times.
*/
@Test
public void testTracking_InQuota() {
spyOn(mQuotaChangeListener);
mQuotaTracker.setCountLimit(SINGLE_CATEGORY, 5, MINUTE_IN_MILLIS);
// Log an event once per minute. This is well below the quota, so listeners should not be
// notified.
for (int i = 0; i < 10; i++) {
advanceElapsedClock(MINUTE_IN_MILLIS);
mQuotaTracker.noteEvent(TEST_USER_ID, TEST_PACKAGE, TEST_TAG);
}
// Wait for some extra time to allow for processing.
verify(mQuotaChangeListener, timeout(3 * SECOND_IN_MILLIS).times(0))
.onQuotaStateChanged(eq(TEST_USER_ID), eq(TEST_PACKAGE), eq(TEST_TAG));
assertTrue(mQuotaTracker.isWithinQuota(TEST_USER_ID, TEST_PACKAGE, TEST_TAG));
}
}