diff --git a/apex/jobscheduler/service/java/com/android/server/job/controllers/QuotaController.java b/apex/jobscheduler/service/java/com/android/server/job/controllers/QuotaController.java index 824fa7fc1659e..2faa8360bf44f 100644 --- a/apex/jobscheduler/service/java/com/android/server/job/controllers/QuotaController.java +++ b/apex/jobscheduler/service/java/com/android/server/job/controllers/QuotaController.java @@ -828,8 +828,8 @@ public final class QuotaController extends StateController { } @NonNull - private ShrinkableDebits getEJQuotaLocked(final int userId, - @NonNull final String packageName) { + @VisibleForTesting + ShrinkableDebits getEJDebitsLocked(final int userId, @NonNull final String packageName) { ShrinkableDebits debits = mEJStats.get(userId, packageName); if (debits == null) { debits = new ShrinkableDebits( @@ -931,7 +931,7 @@ public final class QuotaController extends StateController { @VisibleForTesting long getRemainingEJExecutionTimeLocked(final int userId, @NonNull final String packageName) { - ShrinkableDebits quota = getEJQuotaLocked(userId, packageName); + ShrinkableDebits quota = getEJDebitsLocked(userId, packageName); if (quota.getStandbyBucketLocked() == NEVER_INDEX) { return 0; } @@ -948,7 +948,7 @@ public final class QuotaController extends StateController { if (ts.endTimeElapsed < windowStartTimeElapsed) { final long duration = ts.endTimeElapsed - ts.startTimeElapsed; remainingMs += duration; - quota.transactOnDebitsLocked(-duration); + quota.transactLocked(-duration); timingSessions.remove(0); } else if (ts.startTimeElapsed < windowStartTimeElapsed) { remainingMs += windowStartTimeElapsed - ts.startTimeElapsed; @@ -960,15 +960,16 @@ public final class QuotaController extends StateController { } } + TopAppTimer topAppTimer = mTopAppTrackers.get(userId, packageName); + if (topAppTimer != null && topAppTimer.isActive()) { + remainingMs += topAppTimer.getPendingReward(nowElapsed); + } + Timer timer = mEJPkgTimers.get(userId, packageName); if (timer == null) { return remainingMs; } - // There's a case where the debits tally is 0 but a currently running HPJ still counts - // towards quota. If the app gets a reward in this case, the reward is lost and the HPJ - // run is still fully counted. - // TODO(171305774)/STOPSHIP: make sure getting rewards while HPJ currently executing isn't - // treated negatively + return remainingMs - timer.getCurrentDuration(sElapsedRealtimeClock.millis()); } @@ -1081,7 +1082,7 @@ public final class QuotaController extends StateController { } final long nowElapsed = sElapsedRealtimeClock.millis(); - ShrinkableDebits quota = getEJQuotaLocked(userId, packageName); + ShrinkableDebits quota = getEJDebitsLocked(userId, packageName); final long limitMs = getEJLimitMsLocked(packageName, quota.getStandbyBucketLocked()); final long startWindowElapsed = Math.max(0, nowElapsed - mEJLimitWindowSizeMs); long remainingDeadSpaceMs = remainingExecutionTimeMs; @@ -1372,6 +1373,11 @@ public final class QuotaController extends StateController { @VisibleForTesting void saveTimingSession(final int userId, @NonNull final String packageName, @NonNull final TimingSession session, boolean isExpedited) { + saveTimingSession(userId, packageName, session, isExpedited, 0); + } + + private void saveTimingSession(final int userId, @NonNull final String packageName, + @NonNull final TimingSession session, boolean isExpedited, long debitAdjustment) { synchronized (mLock) { final SparseArrayMap> sessionMap = isExpedited ? mEJTimingSessions : mTimingSessions; @@ -1382,8 +1388,9 @@ public final class QuotaController extends StateController { } sessions.add(session); if (isExpedited) { - final ShrinkableDebits quota = getEJQuotaLocked(userId, packageName); - quota.transactOnDebitsLocked(session.endTimeElapsed - session.startTimeElapsed); + final ShrinkableDebits quota = getEJDebitsLocked(userId, packageName); + quota.transactLocked(session.endTimeElapsed - session.startTimeElapsed + + debitAdjustment); } else { // Adding a new session means that the current stats are now incorrect. invalidateAllExecutionStatsLocked(userId, packageName); @@ -1396,15 +1403,34 @@ public final class QuotaController extends StateController { private void grantRewardForInstantEvent( final int userId, @NonNull final String packageName, final long credit) { synchronized (mLock) { - final ShrinkableDebits quota = getEJQuotaLocked(userId, packageName); - quota.transactOnDebitsLocked(-credit); - if (maybeUpdateConstraintForPkgLocked(sElapsedRealtimeClock.millis(), - userId, packageName)) { + final long nowElapsed = sElapsedRealtimeClock.millis(); + final ShrinkableDebits quota = getEJDebitsLocked(userId, packageName); + if (transactQuotaLocked(userId, packageName, nowElapsed, quota, credit) + && maybeUpdateConstraintForPkgLocked(nowElapsed, userId, packageName)) { mStateChangedListener.onControllerStateChanged(); } } } + private boolean transactQuotaLocked(final int userId, @NonNull final String packageName, + final long nowElapsed, @NonNull ShrinkableDebits debits, final long credit) { + final long oldTally = debits.getTallyLocked(); + final long leftover = debits.transactLocked(-credit); + if (DEBUG) { + Slog.d(TAG, "debits overflowed by " + leftover); + } + boolean changed = oldTally != debits.getTallyLocked(); + if (leftover != 0) { + // Only adjust timer if its active. + final Timer ejTimer = mEJPkgTimers.get(userId, packageName); + if (ejTimer != null && ejTimer.isActive()) { + ejTimer.updateDebitAdjustment(nowElapsed, leftover); + changed = true; + } + } + return changed; + } + private final class EarliestEndTimeFunctor implements Consumer> { public long earliestEndElapsed = Long.MAX_VALUE; @@ -1875,7 +1901,8 @@ public final class QuotaController extends StateController { } } - private static final class ShrinkableDebits { + @VisibleForTesting + static final class ShrinkableDebits { /** The amount of quota remaining. Can be negative if limit changes. */ private long mDebitTally; private int mStandbyBucket; @@ -1893,8 +1920,11 @@ public final class QuotaController extends StateController { * Negative if the tally should decrease (therefore increasing available quota); * or positive if the tally should increase (therefore decreasing available quota). */ - void transactOnDebitsLocked(final long amount) { + long transactLocked(final long amount) { + final long leftover = amount < 0 && Math.abs(amount) > mDebitTally + ? mDebitTally + amount : 0; mDebitTally = Math.max(0, mDebitTally + amount); + return leftover; } void setStandbyBucketLocked(int standbyBucket) { @@ -1927,6 +1957,7 @@ public final class QuotaController extends StateController { private final ArraySet mRunningBgJobs = new ArraySet<>(); private long mStartTimeElapsed; private int mBgJobCount; + private long mDebitAdjustment; Timer(int uid, int userId, String packageName, boolean regularJobTimer) { mPkg = new Package(userId, packageName); @@ -1957,6 +1988,7 @@ public final class QuotaController extends StateController { if (mRunningBgJobs.size() == 1) { // Started tracking the first job. mStartTimeElapsed = sElapsedRealtimeClock.millis(); + mDebitAdjustment = 0; if (mRegularJobTimer) { // Starting the timer means that all cached execution stats are now // incorrect. @@ -1988,6 +2020,11 @@ public final class QuotaController extends StateController { } } + void updateDebitAdjustment(long nowElapsed, long debit) { + // Make sure we don't have a credit larger than the expected session. + mDebitAdjustment = Math.max(mDebitAdjustment + debit, mStartTimeElapsed - nowElapsed); + } + /** * Stops tracking all jobs and cancels any pending alarms. This should only be called if * the Timer is not going to be used anymore. @@ -2003,7 +2040,8 @@ public final class QuotaController extends StateController { return; } TimingSession ts = new TimingSession(mStartTimeElapsed, nowElapsed, mBgJobCount); - saveTimingSession(mPkg.userId, mPkg.packageName, ts, !mRegularJobTimer); + saveTimingSession(mPkg.userId, mPkg.packageName, ts, !mRegularJobTimer, + mDebitAdjustment); mBgJobCount = 0; // Don't reset the tracked jobs list as we need to keep tracking the current number // of jobs. @@ -2030,7 +2068,7 @@ public final class QuotaController extends StateController { long getCurrentDuration(long nowElapsed) { synchronized (mLock) { - return !isActive() ? 0 : nowElapsed - mStartTimeElapsed; + return !isActive() ? 0 : nowElapsed - mStartTimeElapsed + mDebitAdjustment; } } @@ -2059,6 +2097,7 @@ public final class QuotaController extends StateController { // Start timing from unplug. if (mRunningBgJobs.size() > 0) { mStartTimeElapsed = nowElapsed; + mDebitAdjustment = 0; // 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. @@ -2128,6 +2167,11 @@ public final class QuotaController extends StateController { pw.print(", "); pw.print(mBgJobCount); pw.print(" running bg jobs"); + if (!mRegularJobTimer) { + pw.print(" (debit adj="); + pw.print(mDebitAdjustment); + pw.print(")"); + } pw.println(); pw.increaseIndent(); for (int i = 0; i < mRunningBgJobs.size(); i++) { @@ -2171,6 +2215,21 @@ public final class QuotaController extends StateController { mPkg = new Package(userId, packageName); } + private int calculateTimeChunks(final long nowElapsed) { + final long totalTopTimeMs = nowElapsed - mStartTimeElapsed; + int numTimeChunks = (int) (totalTopTimeMs / mEJTopAppTimeChunkSizeMs); + final long remainderMs = totalTopTimeMs % mEJTopAppTimeChunkSizeMs; + if (remainderMs >= SECOND_IN_MILLIS) { + // "Round up" + numTimeChunks++; + } + return numTimeChunks; + } + + long getPendingReward(final long nowElapsed) { + return mEJRewardTopAppMs * calculateTimeChunks(nowElapsed); + } + void processEventLocked(@NonNull UsageEvents.Event event) { final long nowElapsed = sElapsedRealtimeClock.millis(); switch (event.getEventType()) { @@ -2186,21 +2245,16 @@ public final class QuotaController extends StateController { final UsageEvents.Event existingEvent = mActivities.removeReturnOld(event.mInstanceId); if (existingEvent != null && mActivities.size() == 0) { - final long totalTopTimeMs = nowElapsed - mStartTimeElapsed; - int numTimeChunks = (int) (totalTopTimeMs / mEJTopAppTimeChunkSizeMs); - final long remainderMs = totalTopTimeMs % mEJTopAppTimeChunkSizeMs; - if (remainderMs >= SECOND_IN_MILLIS) { - // "Round up" - numTimeChunks++; - } + final long pendingReward = getPendingReward(nowElapsed); if (DEBUG) { - Slog.d(TAG, - "Crediting " + mPkg + " for " + numTimeChunks + " time chunks"); + Slog.d(TAG, "Crediting " + mPkg + " " + pendingReward + "ms" + + " for " + calculateTimeChunks(nowElapsed) + " time chunks"); } - final ShrinkableDebits quota = - getEJQuotaLocked(mPkg.userId, mPkg.packageName); - quota.transactOnDebitsLocked(-mEJRewardTopAppMs * numTimeChunks); - if (maybeUpdateConstraintForPkgLocked(nowElapsed, + final ShrinkableDebits debits = + getEJDebitsLocked(mPkg.userId, mPkg.packageName); + if (transactQuotaLocked(mPkg.userId, mPkg.packageName, + nowElapsed, debits, pendingReward) + && maybeUpdateConstraintForPkgLocked(nowElapsed, mPkg.userId, mPkg.packageName)) { mStateChangedListener.onControllerStateChanged(); } @@ -2321,7 +2375,7 @@ public final class QuotaController extends StateController { */ @Override public void onUsageEvent(int userId, @NonNull UsageEvents.Event event) { - mHandler.obtainMessage(MSG_PROCESS_USAGE_EVENT, userId, 0, event); + mHandler.obtainMessage(MSG_PROCESS_USAGE_EVENT, userId, 0, event).sendToTarget(); } } @@ -2408,7 +2462,6 @@ public final class QuotaController extends StateController { } private class QcHandler extends Handler { - private boolean mIsProcessing; QcHandler(Looper looper) { super(looper); @@ -2417,8 +2470,6 @@ public final class QuotaController extends StateController { @Override public void handleMessage(Message msg) { synchronized (mLock) { - mIsProcessing = true; - switch (msg.what) { case MSG_REACHED_QUOTA: { Package pkg = (Package) msg.obj; @@ -2539,6 +2590,10 @@ public final class QuotaController extends StateController { final int userId = msg.arg1; final UsageEvents.Event event = (UsageEvents.Event) msg.obj; final String pkgName = event.getPackageName(); + if (DEBUG) { + Slog.d(TAG, "Processing event " + event.getEventType() + + " for " + string(userId, pkgName)); + } switch (event.getEventType()) { case UsageEvents.Event.ACTIVITY_RESUMED: case UsageEvents.Event.ACTIVITY_PAUSED: @@ -2604,8 +2659,6 @@ public final class QuotaController extends StateController { } } } - - mIsProcessing = false; } } @@ -3883,11 +3936,6 @@ public final class QuotaController extends StateController { return mQcConstants; } - @VisibleForTesting - boolean isActiveBackgroundProcessing() { - return mHandler.mIsProcessing; - } - //////////////////////////// DATA DUMP ////////////////////////////// @Override diff --git a/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java b/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java index 88a691bbc2097..bf95f4c51d960 100644 --- a/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java +++ b/services/tests/mockingservicestests/src/com/android/server/job/controllers/QuotaControllerTest.java @@ -31,6 +31,7 @@ import static com.android.server.job.JobSchedulerService.RARE_INDEX; import static com.android.server.job.JobSchedulerService.RESTRICTED_INDEX; import static com.android.server.job.JobSchedulerService.WORKING_INDEX; import static com.android.server.job.JobSchedulerService.sElapsedRealtimeClock; +import static com.android.server.job.JobSchedulerService.sSystemClock; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -58,6 +59,7 @@ import android.app.AppGlobals; import android.app.IActivityManager; import android.app.IUidObserver; import android.app.job.JobInfo; +import android.app.usage.UsageEvents; import android.app.usage.UsageStatsManager; import android.app.usage.UsageStatsManagerInternal; import android.content.BroadcastReceiver; @@ -85,6 +87,7 @@ import com.android.server.job.JobSchedulerService; import com.android.server.job.JobStore; import com.android.server.job.controllers.QuotaController.ExecutionStats; import com.android.server.job.controllers.QuotaController.QcConstants; +import com.android.server.job.controllers.QuotaController.ShrinkableDebits; import com.android.server.job.controllers.QuotaController.TimingSession; import com.android.server.usage.AppStandbyInternal; @@ -125,6 +128,7 @@ public class QuotaControllerTest { private int mSourceUid; private PowerAllowlistInternal.TempAllowlistChangeListener mTempAllowlistListener; private IUidObserver mUidObserver; + private UsageStatsManagerInternal.UsageEventListener mUsageEventListener; DeviceConfig.Properties.Builder mDeviceConfigPropertiesBuilder; private MockitoSession mMockingSession; @@ -218,6 +222,8 @@ public class QuotaControllerTest { ArgumentCaptor.forClass(IUidObserver.class); ArgumentCaptor taChangeCaptor = ArgumentCaptor.forClass(PowerAllowlistInternal.TempAllowlistChangeListener.class); + ArgumentCaptor ueListenerCaptor = + ArgumentCaptor.forClass(UsageStatsManagerInternal.UsageEventListener.class); mQuotaController = new QuotaController(mJobSchedulerService, mock(BackgroundJobsController.class), mock(ConnectivityController.class)); @@ -229,6 +235,8 @@ public class QuotaControllerTest { verify(mPowerAllowlistInternal) .registerTempAllowlistChangeListener(taChangeCaptor.capture()); mTempAllowlistListener = taChangeCaptor.getValue(); + verify(mUsageStatsManager).registerListener(ueListenerCaptor.capture()); + mUsageEventListener = ueListenerCaptor.getValue(); try { verify(activityManager).registerUidObserver( uidObserverCaptor.capture(), @@ -288,7 +296,7 @@ public class QuotaControllerTest { verify(foregroundUids, timeout(2 * SECOND_IN_MILLIS).times(1)).delete(eq(uid)); assertFalse(foregroundUids.get(uid)); } - waitForQuietBackground(); + waitForNonDelayedMessagesProcessed(); } catch (Exception e) { fail("exception encountered: " + e.getMessage()); } @@ -385,13 +393,8 @@ public class QuotaControllerTest { } } - private void waitForQuietBackground() throws Exception { - for (int i = 0; i < 5; ++i) { - if (!mQuotaController.isActiveBackgroundProcessing()) { - break; - } - Thread.sleep(500); - } + private void waitForNonDelayedMessagesProcessed() { + mQuotaController.getHandler().runWithScissors(() -> {}, 15_000); } @Test @@ -1330,7 +1333,9 @@ public class QuotaControllerTest { timeUsedMs, 5), true); JobStatus job = createExpeditedJobStatus("testGetMaxJobExecutionTimeLocked_EJ", 0); setStandbyBucket(RARE_INDEX, job); - mQuotaController.maybeStartTrackingJobLocked(job, null); + synchronized (mQuotaController.mLock) { + mQuotaController.maybeStartTrackingJobLocked(job, null); + } setCharging(); synchronized (mQuotaController.mLock) { @@ -5158,4 +5163,257 @@ public class QuotaControllerTest { eq(10 * SECOND_IN_MILLIS)); verify(handler, never()).sendMessageDelayed(any(), eq(remainingTimeMs)); } + + @Test + public void testEJDebitTallying() { + setStandbyBucket(RARE_INDEX); + setProcessState(ActivityManager.PROCESS_STATE_SERVICE); + setDeviceConfigLong(QcConstants.KEY_EJ_LIMIT_RARE_MS, 10 * MINUTE_IN_MILLIS); + // 15 seconds for each 30 second chunk. + setDeviceConfigLong(QcConstants.KEY_EJ_TOP_APP_TIME_CHUNK_SIZE_MS, 30 * SECOND_IN_MILLIS); + setDeviceConfigLong(QcConstants.KEY_EJ_REWARD_TOP_APP_MS, 15 * SECOND_IN_MILLIS); + + // No history. Debits should be 0. + ShrinkableDebits debit = mQuotaController.getEJDebitsLocked(SOURCE_USER_ID, SOURCE_PACKAGE); + assertEquals(0, debit.getTallyLocked()); + assertEquals(10 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + // Regular job shouldn't affect EJ tally. + JobStatus regJob = createJobStatus("testEJDebitTallying", 1); + synchronized (mQuotaController.mLock) { + mQuotaController.maybeStartTrackingJobLocked(regJob, null); + mQuotaController.prepareForExecutionLocked(regJob); + } + advanceElapsedClock(5000); + synchronized (mQuotaController.mLock) { + mQuotaController.maybeStopTrackingJobLocked(regJob, null, false); + } + assertEquals(0, debit.getTallyLocked()); + assertEquals(10 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + // EJ job should affect EJ tally. + JobStatus eJob = createExpeditedJobStatus("testEJDebitTallying", 2); + synchronized (mQuotaController.mLock) { + mQuotaController.maybeStartTrackingJobLocked(eJob, null); + mQuotaController.prepareForExecutionLocked(eJob); + } + advanceElapsedClock(5 * MINUTE_IN_MILLIS); + synchronized (mQuotaController.mLock) { + mQuotaController.maybeStopTrackingJobLocked(eJob, null, false); + } + assertEquals(5 * MINUTE_IN_MILLIS, debit.getTallyLocked()); + assertEquals(5 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + // Instantaneous event for a different user shouldn't affect tally. + advanceElapsedClock(5 * MINUTE_IN_MILLIS); + setDeviceConfigLong(QcConstants.KEY_EJ_REWARD_INTERACTION_MS, MINUTE_IN_MILLIS); + + UsageEvents.Event event = + new UsageEvents.Event(UsageEvents.Event.USER_INTERACTION, sSystemClock.millis()); + event.mPackage = SOURCE_PACKAGE; + mUsageEventListener.onUsageEvent(SOURCE_USER_ID + 10, event); + assertEquals(5 * MINUTE_IN_MILLIS, debit.getTallyLocked()); + + // Instantaneous event for correct user should reduce tally. + advanceElapsedClock(5 * MINUTE_IN_MILLIS); + + mUsageEventListener.onUsageEvent(SOURCE_USER_ID, event); + waitForNonDelayedMessagesProcessed(); + assertEquals(4 * MINUTE_IN_MILLIS, debit.getTallyLocked()); + assertEquals(6 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + // Activity start shouldn't reduce tally, but duration with activity started should affect + // remaining EJ time. + advanceElapsedClock(5 * MINUTE_IN_MILLIS); + event = new UsageEvents.Event(UsageEvents.Event.ACTIVITY_RESUMED, sSystemClock.millis()); + event.mPackage = SOURCE_PACKAGE; + mUsageEventListener.onUsageEvent(SOURCE_USER_ID, event); + waitForNonDelayedMessagesProcessed(); + advanceElapsedClock(30 * SECOND_IN_MILLIS); + assertEquals(4 * MINUTE_IN_MILLIS, debit.getTallyLocked()); + assertEquals(6 * MINUTE_IN_MILLIS + 15 * SECOND_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + advanceElapsedClock(30 * SECOND_IN_MILLIS); + assertEquals(4 * MINUTE_IN_MILLIS, debit.getTallyLocked()); + assertEquals(6 * MINUTE_IN_MILLIS + 30 * SECOND_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + // With activity pausing/stopping/destroying, tally should be updated. + advanceElapsedClock(MINUTE_IN_MILLIS); + event = new UsageEvents.Event(UsageEvents.Event.ACTIVITY_DESTROYED, sSystemClock.millis()); + event.mPackage = SOURCE_PACKAGE; + mUsageEventListener.onUsageEvent(SOURCE_USER_ID, event); + waitForNonDelayedMessagesProcessed(); + assertEquals(3 * MINUTE_IN_MILLIS, debit.getTallyLocked()); + assertEquals(7 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + } + + @Test + public void testEJDebitTallying_StaleSession() { + setStandbyBucket(RARE_INDEX); + setDeviceConfigLong(QcConstants.KEY_EJ_LIMIT_RARE_MS, 10 * MINUTE_IN_MILLIS); + + final long nowElapsed = sElapsedRealtimeClock.millis(); + TimingSession ts = new TimingSession(nowElapsed, nowElapsed + 10 * MINUTE_IN_MILLIS, 5); + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, ts, true); + + // Make the session stale. + advanceElapsedClock(12 * MINUTE_IN_MILLIS + mQcConstants.EJ_WINDOW_SIZE_MS); + + // With lazy deletion, we don't update the tally until getRemainingEJExecutionTimeLocked() + // is called, so call that first. + assertEquals(10 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + ShrinkableDebits debit = mQuotaController.getEJDebitsLocked(SOURCE_USER_ID, SOURCE_PACKAGE); + assertEquals(0, debit.getTallyLocked()); + } + + /** + * Tests that rewards are properly accounted when there's no EJ running and the rewards exceed + * the accumulated debits. + */ + @Test + public void testEJDebitTallying_RewardExceedDebits_NoActiveSession() { + setStandbyBucket(WORKING_INDEX); + setProcessState(ActivityManager.PROCESS_STATE_SERVICE); + setDeviceConfigLong(QcConstants.KEY_EJ_LIMIT_WORKING_MS, 30 * MINUTE_IN_MILLIS); + setDeviceConfigLong(QcConstants.KEY_EJ_REWARD_INTERACTION_MS, MINUTE_IN_MILLIS); + + final long nowElapsed = sElapsedRealtimeClock.millis(); + TimingSession ts = new TimingSession(nowElapsed - 5 * MINUTE_IN_MILLIS, + nowElapsed - 4 * MINUTE_IN_MILLIS, 2); + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, ts, true); + + ShrinkableDebits debit = mQuotaController.getEJDebitsLocked(SOURCE_USER_ID, SOURCE_PACKAGE); + assertEquals(MINUTE_IN_MILLIS, debit.getTallyLocked()); + assertEquals(29 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + advanceElapsedClock(30 * SECOND_IN_MILLIS); + UsageEvents.Event event = + new UsageEvents.Event(UsageEvents.Event.USER_INTERACTION, sSystemClock.millis()); + event.mPackage = SOURCE_PACKAGE; + mUsageEventListener.onUsageEvent(SOURCE_USER_ID, event); + waitForNonDelayedMessagesProcessed(); + assertEquals(0, debit.getTallyLocked()); + assertEquals(30 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + advanceElapsedClock(MINUTE_IN_MILLIS); + assertEquals(0, debit.getTallyLocked()); + assertEquals(30 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + // Excessive rewards don't increase maximum quota. + event = new UsageEvents.Event(UsageEvents.Event.USER_INTERACTION, sSystemClock.millis()); + event.mPackage = SOURCE_PACKAGE; + mUsageEventListener.onUsageEvent(SOURCE_USER_ID, event); + waitForNonDelayedMessagesProcessed(); + assertEquals(0, debit.getTallyLocked()); + assertEquals(30 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + } + + /** + * Tests that rewards are properly accounted when there's an active EJ running and the rewards + * exceed the accumulated debits. + */ + @Test + public void testEJDebitTallying_RewardExceedDebits_ActiveSession() { + setStandbyBucket(WORKING_INDEX); + setProcessState(ActivityManager.PROCESS_STATE_SERVICE); + setDeviceConfigLong(QcConstants.KEY_EJ_LIMIT_WORKING_MS, 30 * MINUTE_IN_MILLIS); + setDeviceConfigLong(QcConstants.KEY_EJ_REWARD_INTERACTION_MS, MINUTE_IN_MILLIS); + // 15 seconds for each 30 second chunk. + setDeviceConfigLong(QcConstants.KEY_EJ_TOP_APP_TIME_CHUNK_SIZE_MS, 30 * SECOND_IN_MILLIS); + setDeviceConfigLong(QcConstants.KEY_EJ_REWARD_TOP_APP_MS, 15 * SECOND_IN_MILLIS); + + final long nowElapsed = sElapsedRealtimeClock.millis(); + TimingSession ts = new TimingSession(nowElapsed - 5 * MINUTE_IN_MILLIS, + nowElapsed - 4 * MINUTE_IN_MILLIS, 2); + mQuotaController.saveTimingSession(SOURCE_USER_ID, SOURCE_PACKAGE, ts, true); + + ShrinkableDebits debit = mQuotaController.getEJDebitsLocked(SOURCE_USER_ID, SOURCE_PACKAGE); + assertEquals(MINUTE_IN_MILLIS, debit.getTallyLocked()); + assertEquals(29 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + // With rewards coming in while an EJ is running, the remaining execution time should be + // adjusted accordingly (decrease due to EJ running + increase from reward). + JobStatus eJob = + createExpeditedJobStatus("testEJDebitTallying_RewardExceedDebits_ActiveSession", 1); + synchronized (mQuotaController.mLock) { + mQuotaController.maybeStartTrackingJobLocked(eJob, null); + mQuotaController.prepareForExecutionLocked(eJob); + } + advanceElapsedClock(30 * SECOND_IN_MILLIS); + assertEquals(MINUTE_IN_MILLIS, debit.getTallyLocked()); + assertEquals(28 * MINUTE_IN_MILLIS + 30 * SECOND_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + advanceElapsedClock(30 * SECOND_IN_MILLIS); + UsageEvents.Event event = + new UsageEvents.Event(UsageEvents.Event.USER_INTERACTION, sSystemClock.millis()); + event.mPackage = SOURCE_PACKAGE; + mUsageEventListener.onUsageEvent(SOURCE_USER_ID, event); + waitForNonDelayedMessagesProcessed(); + assertEquals(0, debit.getTallyLocked()); + assertEquals(29 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + advanceElapsedClock(MINUTE_IN_MILLIS); + assertEquals(0, debit.getTallyLocked()); + assertEquals(28 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + // Activity start shouldn't reduce tally, but duration with activity started should affect + // remaining EJ time. + event = new UsageEvents.Event(UsageEvents.Event.ACTIVITY_RESUMED, sSystemClock.millis()); + event.mPackage = SOURCE_PACKAGE; + mUsageEventListener.onUsageEvent(SOURCE_USER_ID, event); + waitForNonDelayedMessagesProcessed(); + advanceElapsedClock(30 * SECOND_IN_MILLIS); + assertEquals(0, debit.getTallyLocked()); + // Decrease by 30 seconds for running EJ, increase by 15 seconds due to ongoing activity. + assertEquals(27 * MINUTE_IN_MILLIS + 45 * SECOND_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + advanceElapsedClock(30 * SECOND_IN_MILLIS); + assertEquals(0, debit.getTallyLocked()); + assertEquals(27 * MINUTE_IN_MILLIS + 30 * SECOND_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + advanceElapsedClock(MINUTE_IN_MILLIS); + assertEquals(0, debit.getTallyLocked()); + assertEquals(27 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + event = new UsageEvents.Event(UsageEvents.Event.USER_INTERACTION, sSystemClock.millis()); + event.mPackage = SOURCE_PACKAGE; + mUsageEventListener.onUsageEvent(SOURCE_USER_ID, event); + waitForNonDelayedMessagesProcessed(); + assertEquals(0, debit.getTallyLocked()); + assertEquals(28 * MINUTE_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + advanceElapsedClock(MINUTE_IN_MILLIS); + assertEquals(0, debit.getTallyLocked()); + assertEquals(27 * MINUTE_IN_MILLIS + 30 * SECOND_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + + // At this point, with activity pausing/stopping/destroying, since we're giving a reward, + // tally should remain 0, and time remaining shouldn't change since it was accounted for + // at every step. + event = new UsageEvents.Event(UsageEvents.Event.ACTIVITY_DESTROYED, sSystemClock.millis()); + event.mPackage = SOURCE_PACKAGE; + mUsageEventListener.onUsageEvent(SOURCE_USER_ID, event); + waitForNonDelayedMessagesProcessed(); + assertEquals(0, debit.getTallyLocked()); + assertEquals(27 * MINUTE_IN_MILLIS + 30 * SECOND_IN_MILLIS, + mQuotaController.getRemainingEJExecutionTimeLocked(SOURCE_USER_ID, SOURCE_PACKAGE)); + } }