/* * Copyright (C) 2021 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.tare; import static android.app.tare.EconomyManager.ENABLED_MODE_OFF; import static android.text.format.DateUtils.DAY_IN_MILLIS; import static com.android.server.tare.EconomicPolicy.REGULATION_BASIC_INCOME; import static com.android.server.tare.EconomicPolicy.REGULATION_BG_RESTRICTED; import static com.android.server.tare.EconomicPolicy.REGULATION_BG_UNRESTRICTED; import static com.android.server.tare.EconomicPolicy.REGULATION_BIRTHRIGHT; import static com.android.server.tare.EconomicPolicy.REGULATION_DEMOTION; import static com.android.server.tare.EconomicPolicy.REGULATION_PROMOTION; import static com.android.server.tare.EconomicPolicy.REGULATION_WEALTH_RECLAMATION; import static com.android.server.tare.EconomicPolicy.TYPE_ACTION; import static com.android.server.tare.EconomicPolicy.TYPE_REWARD; import static com.android.server.tare.EconomicPolicy.eventToString; import static com.android.server.tare.EconomicPolicy.getEventType; import static com.android.server.tare.TareUtils.appToString; import static com.android.server.tare.TareUtils.cakeToString; import static com.android.server.tare.TareUtils.getCurrentTimeMillis; import android.annotation.NonNull; import android.annotation.Nullable; import android.content.Context; import android.content.pm.UserPackage; import android.os.Handler; import android.os.Looper; import android.os.Message; import android.os.SystemClock; import android.os.UserHandle; import android.util.ArraySet; import android.util.IndentingPrintWriter; import android.util.Log; import android.util.Slog; import android.util.SparseArrayMap; import android.util.SparseSetArray; import android.util.TimeUtils; import com.android.internal.annotations.GuardedBy; import com.android.internal.annotations.VisibleForTesting; import com.android.server.LocalServices; import com.android.server.pm.UserManagerInternal; import com.android.server.usage.AppStandbyInternal; import com.android.server.utils.AlarmQueue; import java.util.List; import java.util.Objects; import java.util.function.Consumer; /** * Other half of the IRS. The agent handles the nitty gritty details, interacting directly with * ledgers, carrying out specific events such as wealth reclamation, granting initial balances or * replenishing balances, and tracking ongoing events. */ class Agent { private static final String TAG = "TARE-" + Agent.class.getSimpleName(); private static final boolean DEBUG = InternalResourceService.DEBUG || Log.isLoggable(TAG, Log.DEBUG); private static final String ALARM_TAG_AFFORDABILITY_CHECK = "*tare.affordability_check*"; private final Object mLock; private final Handler mHandler; private final Analyst mAnalyst; private final InternalResourceService mIrs; private final Scribe mScribe; private final AppStandbyInternal mAppStandbyInternal; @GuardedBy("mLock") private final SparseArrayMap> mCurrentOngoingEvents = new SparseArrayMap<>(); /** * Set of {@link ActionAffordabilityNote ActionAffordabilityNotes} keyed by userId-pkgName. * * Note: it would be nice/better to sort by base price since that doesn't change and simply * look at the change in the "insertion" of what would be affordable, but since CTP * is factored into the final price, the sorting order (by modified price) could be different * and that method wouldn't work >:( */ @GuardedBy("mLock") private final SparseArrayMap> mActionAffordabilityNotes = new SparseArrayMap<>(); /** * Queue to track and manage when apps will cross the closest affordability threshold (in * both directions). */ @GuardedBy("mLock") private final BalanceThresholdAlarmQueue mBalanceThresholdAlarmQueue; /** * Check the affordability notes of all apps. */ private static final int MSG_CHECK_ALL_AFFORDABILITY = 0; /** * Check the affordability notes of a single app. */ private static final int MSG_CHECK_INDIVIDUAL_AFFORDABILITY = 1; Agent(@NonNull InternalResourceService irs, @NonNull Scribe scribe, @NonNull Analyst analyst) { mLock = irs.getLock(); mIrs = irs; mScribe = scribe; mAnalyst = analyst; mHandler = new AgentHandler(TareHandlerThread.get().getLooper()); mAppStandbyInternal = LocalServices.getService(AppStandbyInternal.class); mBalanceThresholdAlarmQueue = new BalanceThresholdAlarmQueue( mIrs.getContext(), TareHandlerThread.get().getLooper()); } private class TotalDeltaCalculator implements Consumer { private Ledger mLedger; private long mNowElapsed; private long mNow; private long mTotal; void reset(@NonNull Ledger ledger, long nowElapsed, long now) { mLedger = ledger; mNowElapsed = nowElapsed; mNow = now; mTotal = 0; } @Override public void accept(OngoingEvent ongoingEvent) { mTotal += getActualDeltaLocked(ongoingEvent, mLedger, mNowElapsed, mNow).price; } } @GuardedBy("mLock") private final TotalDeltaCalculator mTotalDeltaCalculator = new TotalDeltaCalculator(); /** Get an app's current balance, factoring in any currently ongoing events. */ @GuardedBy("mLock") long getBalanceLocked(final int userId, @NonNull final String pkgName) { final Ledger ledger = mScribe.getLedgerLocked(userId, pkgName); long balance = ledger.getCurrentBalance(); SparseArrayMap ongoingEvents = mCurrentOngoingEvents.get(userId, pkgName); if (ongoingEvents != null) { final long nowElapsed = SystemClock.elapsedRealtime(); final long now = getCurrentTimeMillis(); mTotalDeltaCalculator.reset(ledger, nowElapsed, now); ongoingEvents.forEach(mTotalDeltaCalculator); balance += mTotalDeltaCalculator.mTotal; } return balance; } @GuardedBy("mLock") private boolean isAffordableLocked(long balance, long price, long stockLimitHonoringCtp) { return balance >= price && mScribe.getRemainingConsumableCakesLocked() >= stockLimitHonoringCtp; } @GuardedBy("mLock") void noteInstantaneousEventLocked(final int userId, @NonNull final String pkgName, final int eventId, @Nullable String tag) { if (mIrs.isSystem(userId, pkgName)) { // Events are free for the system. Don't bother recording them. return; } final long now = getCurrentTimeMillis(); final Ledger ledger = mScribe.getLedgerLocked(userId, pkgName); final CompleteEconomicPolicy economicPolicy = mIrs.getCompleteEconomicPolicyLocked(); final int eventType = getEventType(eventId); switch (eventType) { case TYPE_ACTION: final EconomicPolicy.Cost actionCost = economicPolicy.getCostOfAction(eventId, userId, pkgName); recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(now, now, eventId, tag, -actionCost.price, actionCost.costToProduce), true); break; case TYPE_REWARD: final EconomicPolicy.Reward reward = economicPolicy.getReward(eventId); if (reward != null) { final long rewardSum = ledger.get24HourSum(eventId, now); final long rewardVal = Math.max(0, Math.min(reward.maxDailyReward - rewardSum, reward.instantReward)); recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(now, now, eventId, tag, rewardVal, 0), true); } break; default: Slog.w(TAG, "Unsupported event type: " + eventType); } scheduleBalanceCheckLocked(userId, pkgName); } @GuardedBy("mLock") void noteOngoingEventLocked(final int userId, @NonNull final String pkgName, final int eventId, @Nullable String tag, final long startElapsed) { noteOngoingEventLocked(userId, pkgName, eventId, tag, startElapsed, true); } @GuardedBy("mLock") private void noteOngoingEventLocked(final int userId, @NonNull final String pkgName, final int eventId, @Nullable String tag, final long startElapsed, final boolean updateBalanceCheck) { if (mIrs.isSystem(userId, pkgName)) { // Events are free for the system. Don't bother recording them. return; } SparseArrayMap ongoingEvents = mCurrentOngoingEvents.get(userId, pkgName); if (ongoingEvents == null) { ongoingEvents = new SparseArrayMap<>(); mCurrentOngoingEvents.add(userId, pkgName, ongoingEvents); } OngoingEvent ongoingEvent = ongoingEvents.get(eventId, tag); final CompleteEconomicPolicy economicPolicy = mIrs.getCompleteEconomicPolicyLocked(); final int eventType = getEventType(eventId); switch (eventType) { case TYPE_ACTION: final EconomicPolicy.Cost actionCost = economicPolicy.getCostOfAction(eventId, userId, pkgName); if (ongoingEvent == null) { ongoingEvents.add(eventId, tag, new OngoingEvent(eventId, tag, startElapsed, actionCost)); } else { ongoingEvent.refCount++; } break; case TYPE_REWARD: final EconomicPolicy.Reward reward = economicPolicy.getReward(eventId); if (reward != null) { if (ongoingEvent == null) { ongoingEvents.add(eventId, tag, new OngoingEvent( eventId, tag, startElapsed, reward)); } else { ongoingEvent.refCount++; } } break; default: Slog.w(TAG, "Unsupported event type: " + eventType); } if (updateBalanceCheck) { scheduleBalanceCheckLocked(userId, pkgName); } } @GuardedBy("mLock") void onDeviceStateChangedLocked() { onPricingChangedLocked(); } @GuardedBy("mLock") void onPricingChangedLocked() { onAnythingChangedLocked(true); } @GuardedBy("mLock") void onAppStatesChangedLocked(final int userId, @NonNull ArraySet pkgNames) { final long now = getCurrentTimeMillis(); final long nowElapsed = SystemClock.elapsedRealtime(); final CompleteEconomicPolicy economicPolicy = mIrs.getCompleteEconomicPolicyLocked(); for (int i = 0; i < pkgNames.size(); ++i) { final String pkgName = pkgNames.valueAt(i); final boolean isVip = mIrs.isVip(userId, pkgName, nowElapsed); SparseArrayMap ongoingEvents = mCurrentOngoingEvents.get(userId, pkgName); if (ongoingEvents != null) { mOngoingEventUpdater.reset(userId, pkgName, now, nowElapsed); ongoingEvents.forEach(mOngoingEventUpdater); final ArraySet actionAffordabilityNotes = mActionAffordabilityNotes.get(userId, pkgName); if (actionAffordabilityNotes != null) { final int size = actionAffordabilityNotes.size(); final long newBalance = mScribe.getLedgerLocked(userId, pkgName).getCurrentBalance(); for (int n = 0; n < size; ++n) { final ActionAffordabilityNote note = actionAffordabilityNotes.valueAt(n); note.recalculateCosts(economicPolicy, userId, pkgName); final boolean isAffordable = isVip || isAffordableLocked(newBalance, note.getCachedModifiedPrice(), note.getStockLimitHonoringCtp()); if (note.isCurrentlyAffordable() != isAffordable) { note.setNewAffordability(isAffordable); mIrs.postAffordabilityChanged(userId, pkgName, note); } } } scheduleBalanceCheckLocked(userId, pkgName); } } } @GuardedBy("mLock") void onVipStatusChangedLocked(final int userId, @NonNull String pkgName) { final long now = getCurrentTimeMillis(); final long nowElapsed = SystemClock.elapsedRealtime(); final CompleteEconomicPolicy economicPolicy = mIrs.getCompleteEconomicPolicyLocked(); final boolean isVip = mIrs.isVip(userId, pkgName, nowElapsed); SparseArrayMap ongoingEvents = mCurrentOngoingEvents.get(userId, pkgName); if (ongoingEvents != null) { mOngoingEventUpdater.reset(userId, pkgName, now, nowElapsed); ongoingEvents.forEach(mOngoingEventUpdater); } final ArraySet actionAffordabilityNotes = mActionAffordabilityNotes.get(userId, pkgName); if (actionAffordabilityNotes != null) { final int size = actionAffordabilityNotes.size(); final long newBalance = mScribe.getLedgerLocked(userId, pkgName).getCurrentBalance(); for (int n = 0; n < size; ++n) { final ActionAffordabilityNote note = actionAffordabilityNotes.valueAt(n); note.recalculateCosts(economicPolicy, userId, pkgName); final boolean isAffordable = isVip || isAffordableLocked(newBalance, note.getCachedModifiedPrice(), note.getStockLimitHonoringCtp()); if (note.isCurrentlyAffordable() != isAffordable) { note.setNewAffordability(isAffordable); mIrs.postAffordabilityChanged(userId, pkgName, note); } } } scheduleBalanceCheckLocked(userId, pkgName); } @GuardedBy("mLock") void onVipStatusChangedLocked(@NonNull SparseSetArray pkgs) { for (int u = pkgs.size() - 1; u >= 0; --u) { final int userId = pkgs.keyAt(u); for (int p = pkgs.sizeAt(u) - 1; p >= 0; --p) { onVipStatusChangedLocked(userId, pkgs.valueAt(u, p)); } } } @GuardedBy("mLock") private void onAnythingChangedLocked(final boolean updateOngoingEvents) { final long now = getCurrentTimeMillis(); final long nowElapsed = SystemClock.elapsedRealtime(); final CompleteEconomicPolicy economicPolicy = mIrs.getCompleteEconomicPolicyLocked(); for (int uIdx = mCurrentOngoingEvents.numMaps() - 1; uIdx >= 0; --uIdx) { final int userId = mCurrentOngoingEvents.keyAt(uIdx); for (int pIdx = mCurrentOngoingEvents.numElementsForKey(userId) - 1; pIdx >= 0; --pIdx) { final String pkgName = mCurrentOngoingEvents.keyAt(uIdx, pIdx); SparseArrayMap ongoingEvents = mCurrentOngoingEvents.valueAt(uIdx, pIdx); if (ongoingEvents != null) { if (updateOngoingEvents) { mOngoingEventUpdater.reset(userId, pkgName, now, nowElapsed); ongoingEvents.forEach(mOngoingEventUpdater); } scheduleBalanceCheckLocked(userId, pkgName); } } } for (int uIdx = mActionAffordabilityNotes.numMaps() - 1; uIdx >= 0; --uIdx) { final int userId = mActionAffordabilityNotes.keyAt(uIdx); for (int pIdx = mActionAffordabilityNotes.numElementsForKey(userId) - 1; pIdx >= 0; --pIdx) { final String pkgName = mActionAffordabilityNotes.keyAt(uIdx, pIdx); final ArraySet actionAffordabilityNotes = mActionAffordabilityNotes.valueAt(uIdx, pIdx); if (actionAffordabilityNotes != null) { final int size = actionAffordabilityNotes.size(); final long newBalance = getBalanceLocked(userId, pkgName); final boolean isVip = mIrs.isVip(userId, pkgName, nowElapsed); for (int n = 0; n < size; ++n) { final ActionAffordabilityNote note = actionAffordabilityNotes.valueAt(n); note.recalculateCosts(economicPolicy, userId, pkgName); final boolean isAffordable = isVip || isAffordableLocked(newBalance, note.getCachedModifiedPrice(), note.getStockLimitHonoringCtp()); if (note.isCurrentlyAffordable() != isAffordable) { note.setNewAffordability(isAffordable); mIrs.postAffordabilityChanged(userId, pkgName, note); } } } } } } @GuardedBy("mLock") void stopOngoingActionLocked(final int userId, @NonNull final String pkgName, final int eventId, @Nullable String tag, final long nowElapsed, final long now) { stopOngoingActionLocked(userId, pkgName, eventId, tag, nowElapsed, now, true, true); } /** * @param updateBalanceCheck Whether to reschedule the affordability/balance * check alarm. * @param notifyOnAffordabilityChange Whether to evaluate the app's ability to afford * registered bills and notify listeners about any changes. */ @GuardedBy("mLock") private void stopOngoingActionLocked(final int userId, @NonNull final String pkgName, final int eventId, @Nullable String tag, final long nowElapsed, final long now, final boolean updateBalanceCheck, final boolean notifyOnAffordabilityChange) { if (mIrs.isSystem(userId, pkgName)) { // Events are free for the system. Don't bother recording them. return; } final Ledger ledger = mScribe.getLedgerLocked(userId, pkgName); SparseArrayMap ongoingEvents = mCurrentOngoingEvents.get(userId, pkgName); if (ongoingEvents == null) { // This may occur if TARE goes from disabled to enabled while an event is already // occurring. Slog.w(TAG, "No ongoing transactions for " + appToString(userId, pkgName)); return; } final OngoingEvent ongoingEvent = ongoingEvents.get(eventId, tag); if (ongoingEvent == null) { // This may occur if TARE goes from disabled to enabled while an event is already // occurring. Slog.w(TAG, "Nonexistent ongoing transaction " + eventToString(eventId) + (tag == null ? "" : ":" + tag) + " for " + appToString(userId, pkgName) + " ended"); return; } ongoingEvent.refCount--; if (ongoingEvent.refCount <= 0) { final long startElapsed = ongoingEvent.startTimeElapsed; final long startTime = now - (nowElapsed - startElapsed); final EconomicPolicy.Cost actualDelta = getActualDeltaLocked(ongoingEvent, ledger, nowElapsed, now); recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(startTime, now, eventId, tag, actualDelta.price, actualDelta.costToProduce), notifyOnAffordabilityChange); ongoingEvents.delete(eventId, tag); } if (updateBalanceCheck) { scheduleBalanceCheckLocked(userId, pkgName); } } @GuardedBy("mLock") @NonNull private EconomicPolicy.Cost getActualDeltaLocked(@NonNull OngoingEvent ongoingEvent, @NonNull Ledger ledger, long nowElapsed, long now) { final long startElapsed = ongoingEvent.startTimeElapsed; final long durationSecs = (nowElapsed - startElapsed) / 1000; final long computedDelta = durationSecs * ongoingEvent.getDeltaPerSec(); if (ongoingEvent.reward == null) { return new EconomicPolicy.Cost( durationSecs * ongoingEvent.getCtpPerSec(), computedDelta); } final long rewardSum = ledger.get24HourSum(ongoingEvent.eventId, now); return new EconomicPolicy.Cost(0, Math.max(0, Math.min(ongoingEvent.reward.maxDailyReward - rewardSum, computedDelta))); } @VisibleForTesting @GuardedBy("mLock") void recordTransactionLocked(final int userId, @NonNull final String pkgName, @NonNull Ledger ledger, @NonNull Ledger.Transaction transaction, final boolean notifyOnAffordabilityChange) { if (!DEBUG && transaction.delta == 0) { // Skip recording transactions with a delta of 0 to save on space. return; } if (mIrs.isSystem(userId, pkgName)) { Slog.wtfStack(TAG, "Tried to adjust system balance for " + appToString(userId, pkgName)); return; } final boolean isVip = mIrs.isVip(userId, pkgName); if (isVip) { // This could happen if the app was made a VIP after it started performing actions. // Continue recording the transaction for debugging purposes, but don't let it change // any numbers. transaction = new Ledger.Transaction( transaction.startTimeMs, transaction.endTimeMs, transaction.eventId, transaction.tag, 0 /* delta */, transaction.ctp); } final CompleteEconomicPolicy economicPolicy = mIrs.getCompleteEconomicPolicyLocked(); final long originalBalance = ledger.getCurrentBalance(); final long maxBalance = economicPolicy.getMaxSatiatedBalance(userId, pkgName); if (transaction.delta > 0 && originalBalance + transaction.delta > maxBalance) { // Set lower bound at 0 so we don't accidentally take away credits when we were trying // to _give_ the app credits. final long newDelta = Math.max(0, maxBalance - originalBalance); Slog.i(TAG, "Would result in becoming too rich. Decreasing transaction " + eventToString(transaction.eventId) + (transaction.tag == null ? "" : ":" + transaction.tag) + " for " + appToString(userId, pkgName) + " by " + cakeToString(transaction.delta - newDelta)); transaction = new Ledger.Transaction( transaction.startTimeMs, transaction.endTimeMs, transaction.eventId, transaction.tag, newDelta, transaction.ctp); } ledger.recordTransaction(transaction); mScribe.adjustRemainingConsumableCakesLocked(-transaction.ctp); mAnalyst.noteTransaction(transaction); if (transaction.delta != 0 && notifyOnAffordabilityChange) { final ArraySet actionAffordabilityNotes = mActionAffordabilityNotes.get(userId, pkgName); if (actionAffordabilityNotes != null) { final long newBalance = ledger.getCurrentBalance(); for (int i = 0; i < actionAffordabilityNotes.size(); ++i) { final ActionAffordabilityNote note = actionAffordabilityNotes.valueAt(i); final boolean isAffordable = isVip || isAffordableLocked(newBalance, note.getCachedModifiedPrice(), note.getStockLimitHonoringCtp()); if (note.isCurrentlyAffordable() != isAffordable) { note.setNewAffordability(isAffordable); mIrs.postAffordabilityChanged(userId, pkgName, note); } } } } if (transaction.ctp != 0) { mHandler.sendEmptyMessage(MSG_CHECK_ALL_AFFORDABILITY); mIrs.maybePerformQuantitativeEasingLocked(); } } @GuardedBy("mLock") void reclaimAllAssetsLocked(final int userId, @NonNull final String pkgName, int regulationId) { final Ledger ledger = mScribe.getLedgerLocked(userId, pkgName); final long curBalance = ledger.getCurrentBalance(); if (curBalance <= 0) { return; } if (DEBUG) { Slog.i(TAG, "Reclaiming " + cakeToString(curBalance) + " from " + appToString(userId, pkgName) + " because of " + eventToString(regulationId)); } final long now = getCurrentTimeMillis(); recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(now, now, regulationId, null, -curBalance, 0), true); } /** * Reclaim a percentage of unused ARCs from every app that hasn't been used recently. The * reclamation will not reduce an app's balance below its minimum balance as dictated by * {@code scaleMinBalance}. * * @param percentage A value between 0 and 1 to indicate how much of the unused balance * should be reclaimed. * @param minUnusedTimeMs The minimum amount of time (in milliseconds) that must have * transpired since the last user usage event before we will consider * reclaiming ARCs from the app. * @param scaleMinBalance Whether or not to use the scaled minimum app balance. If false, * this will use the constant min balance floor given by * {@link EconomicPolicy#getMinSatiatedBalance(int, String)}. If true, * this will use the scaled balance given by * {@link InternalResourceService#getMinBalanceLocked(int, String)}. */ @GuardedBy("mLock") void reclaimUnusedAssetsLocked(double percentage, long minUnusedTimeMs, boolean scaleMinBalance) { final CompleteEconomicPolicy economicPolicy = mIrs.getCompleteEconomicPolicyLocked(); final SparseArrayMap ledgers = mScribe.getLedgersLocked(); final long now = getCurrentTimeMillis(); for (int u = 0; u < ledgers.numMaps(); ++u) { final int userId = ledgers.keyAt(u); for (int p = 0; p < ledgers.numElementsForKey(userId); ++p) { final Ledger ledger = ledgers.valueAt(u, p); final long curBalance = ledger.getCurrentBalance(); if (curBalance <= 0) { continue; } final String pkgName = ledgers.keyAt(u, p); // AppStandby only counts elapsed time for things like this // TODO: should we use clock time instead? final long timeSinceLastUsedMs = mAppStandbyInternal.getTimeSinceLastUsedByUser(pkgName, userId); if (timeSinceLastUsedMs >= minUnusedTimeMs) { final long minBalance; if (!scaleMinBalance) { // Use a constant floor instead of the scaled floor from the IRS. minBalance = economicPolicy.getMinSatiatedBalance(userId, pkgName); } else { minBalance = mIrs.getMinBalanceLocked(userId, pkgName); } long toReclaim = (long) (curBalance * percentage); if (curBalance - toReclaim < minBalance) { toReclaim = curBalance - minBalance; } if (toReclaim > 0) { if (DEBUG) { Slog.i(TAG, "Reclaiming unused wealth! Taking " + cakeToString(toReclaim) + " from " + appToString(userId, pkgName)); } recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(now, now, REGULATION_WEALTH_RECLAMATION, null, -toReclaim, 0), true); } } } } } /** * Reclaim a percentage of unused ARCs from an app that was just removed from an exemption list. * The amount reclaimed will depend on how recently the app was used. The reclamation will not * reduce an app's balance below its current minimum balance. */ @GuardedBy("mLock") void onAppUnexemptedLocked(final int userId, @NonNull final String pkgName) { final long curBalance = getBalanceLocked(userId, pkgName); final long minBalance = mIrs.getMinBalanceLocked(userId, pkgName); if (curBalance <= minBalance) { return; } // AppStandby only counts elapsed time for things like this // TODO: should we use clock time instead? final long timeSinceLastUsedMs = mAppStandbyInternal.getTimeSinceLastUsedByUser(pkgName, userId); // The app is no longer exempted. We should take away some of credits so it's more in line // with other non-exempt apps. However, don't take away as many credits if the app was used // recently. final double percentageToReclaim; if (timeSinceLastUsedMs < DAY_IN_MILLIS) { percentageToReclaim = .25; } else if (timeSinceLastUsedMs < 2 * DAY_IN_MILLIS) { percentageToReclaim = .5; } else if (timeSinceLastUsedMs < 3 * DAY_IN_MILLIS) { percentageToReclaim = .75; } else { percentageToReclaim = 1; } final long overage = curBalance - minBalance; final long toReclaim = (long) (overage * percentageToReclaim); if (toReclaim > 0) { if (DEBUG) { Slog.i(TAG, "Reclaiming bonus wealth! Taking " + toReclaim + " from " + appToString(userId, pkgName)); } final long now = getCurrentTimeMillis(); final Ledger ledger = mScribe.getLedgerLocked(userId, pkgName); recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(now, now, REGULATION_DEMOTION, null, -toReclaim, 0), true); } } /** * Reclaim all ARCs from an app that was just restricted. */ @GuardedBy("mLock") void onAppRestrictedLocked(final int userId, @NonNull final String pkgName) { reclaimAllAssetsLocked(userId, pkgName, REGULATION_BG_RESTRICTED); } /** * Give an app that was just unrestricted some ARCs. */ @GuardedBy("mLock") void onAppUnrestrictedLocked(final int userId, @NonNull final String pkgName) { final Ledger ledger = mScribe.getLedgerLocked(userId, pkgName); if (ledger.getCurrentBalance() > 0) { Slog.wtf(TAG, "App " + pkgName + " had credits while it was restricted"); // App already got credits somehow. Move along. return; } final long now = getCurrentTimeMillis(); recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(now, now, REGULATION_BG_UNRESTRICTED, null, mIrs.getMinBalanceLocked(userId, pkgName), 0), true); } /** Returns true if an app should be given credits in the general distributions. */ private boolean shouldGiveCredits(@NonNull InstalledPackageInfo packageInfo) { // Skip apps that wouldn't be doing any work. Giving them ARCs would be wasteful. if (!packageInfo.hasCode) { return false; } final int userId = UserHandle.getUserId(packageInfo.uid); // No point allocating ARCs to the system. It can do whatever it wants. return !mIrs.isSystem(userId, packageInfo.packageName) && !mIrs.isPackageRestricted(userId, packageInfo.packageName); } void onCreditSupplyChanged() { mHandler.sendEmptyMessage(MSG_CHECK_ALL_AFFORDABILITY); } @GuardedBy("mLock") void distributeBasicIncomeLocked(int batteryLevel) { final SparseArrayMap pkgs = mIrs.getInstalledPackages(); final long now = getCurrentTimeMillis(); for (int uIdx = pkgs.numMaps() - 1; uIdx >= 0; --uIdx) { final int userId = pkgs.keyAt(uIdx); for (int pIdx = pkgs.numElementsForKeyAt(uIdx) - 1; pIdx >= 0; --pIdx) { final InstalledPackageInfo pkgInfo = pkgs.valueAt(uIdx, pIdx); if (!shouldGiveCredits(pkgInfo)) { continue; } final String pkgName = pkgInfo.packageName; final Ledger ledger = mScribe.getLedgerLocked(userId, pkgName); final long minBalance = mIrs.getMinBalanceLocked(userId, pkgName); final double perc = batteryLevel / 100d; // TODO: maybe don't give credits to bankrupt apps until battery level >= 50% final long shortfall = minBalance - ledger.getCurrentBalance(); if (shortfall > 0) { recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(now, now, REGULATION_BASIC_INCOME, null, (long) (perc * shortfall), 0), true); } } } } /** Give each app an initial balance. */ @GuardedBy("mLock") void grantBirthrightsLocked() { UserManagerInternal userManagerInternal = LocalServices.getService(UserManagerInternal.class); final int[] userIds = userManagerInternal.getUserIds(); for (int userId : userIds) { grantBirthrightsLocked(userId); } } @GuardedBy("mLock") void grantBirthrightsLocked(final int userId) { final List pkgs = mIrs.getInstalledPackages(userId); final long now = getCurrentTimeMillis(); for (int i = 0; i < pkgs.size(); ++i) { final InstalledPackageInfo packageInfo = pkgs.get(i); if (!shouldGiveCredits(packageInfo)) { continue; } final String pkgName = packageInfo.packageName; final Ledger ledger = mScribe.getLedgerLocked(userId, pkgName); if (ledger.getCurrentBalance() > 0) { // App already got credits somehow. Move along. Slog.wtf(TAG, "App " + pkgName + " had credits before economy was set up"); continue; } recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(now, now, REGULATION_BIRTHRIGHT, null, mIrs.getMinBalanceLocked(userId, pkgName), 0), true); } } @GuardedBy("mLock") void grantBirthrightLocked(final int userId, @NonNull final String pkgName) { final Ledger ledger = mScribe.getLedgerLocked(userId, pkgName); if (ledger.getCurrentBalance() > 0) { Slog.wtf(TAG, "App " + pkgName + " had credits as soon as it was installed"); // App already got credits somehow. Move along. return; } final long now = getCurrentTimeMillis(); recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(now, now, REGULATION_BIRTHRIGHT, null, mIrs.getMinBalanceLocked(userId, pkgName), 0), true); } @GuardedBy("mLock") void onAppExemptedLocked(final int userId, @NonNull final String pkgName) { final long minBalance = mIrs.getMinBalanceLocked(userId, pkgName); final long missing = minBalance - getBalanceLocked(userId, pkgName); if (missing <= 0) { return; } final Ledger ledger = mScribe.getLedgerLocked(userId, pkgName); final long now = getCurrentTimeMillis(); recordTransactionLocked(userId, pkgName, ledger, new Ledger.Transaction(now, now, REGULATION_PROMOTION, null, missing, 0), true); } @GuardedBy("mLock") void onPackageRemovedLocked(final int userId, @NonNull final String pkgName) { mScribe.discardLedgerLocked(userId, pkgName); mCurrentOngoingEvents.delete(userId, pkgName); mBalanceThresholdAlarmQueue.removeAlarmForKey(UserPackage.of(userId, pkgName)); } @GuardedBy("mLock") void onUserRemovedLocked(final int userId) { mCurrentOngoingEvents.delete(userId); mBalanceThresholdAlarmQueue.removeAlarmsForUserId(userId); } @VisibleForTesting static class TrendCalculator implements Consumer { static final long WILL_NOT_CROSS_THRESHOLD = -1; private long mCurBalance; private long mRemainingConsumableCredits; /** * The maximum change in credits per second towards the upper threshold * {@link #mUpperThreshold}. A value of 0 means the current ongoing events will never * result in the app crossing the upper threshold. */ private long mMaxDeltaPerSecToUpperThreshold; /** * The maximum change in credits per second towards the lower threshold * {@link #mLowerThreshold}. A value of 0 means the current ongoing events will never * result in the app crossing the lower threshold. */ private long mMaxDeltaPerSecToLowerThreshold; /** * The maximum change in credits per second towards the highest CTP threshold below the * remaining consumable credits (cached in {@link #mCtpThreshold}). A value of 0 means * the current ongoing events will never result in the app crossing the lower threshold. */ private long mMaxDeltaPerSecToCtpThreshold; private long mUpperThreshold; private long mLowerThreshold; private long mCtpThreshold; void reset(long curBalance, long remainingConsumableCredits, @Nullable ArraySet actionAffordabilityNotes) { mCurBalance = curBalance; mRemainingConsumableCredits = remainingConsumableCredits; mMaxDeltaPerSecToUpperThreshold = mMaxDeltaPerSecToLowerThreshold = 0; mMaxDeltaPerSecToCtpThreshold = 0; mUpperThreshold = Long.MIN_VALUE; mLowerThreshold = Long.MAX_VALUE; mCtpThreshold = 0; if (actionAffordabilityNotes != null) { for (int i = 0; i < actionAffordabilityNotes.size(); ++i) { final ActionAffordabilityNote note = actionAffordabilityNotes.valueAt(i); final long price = note.getCachedModifiedPrice(); if (price <= mCurBalance) { mLowerThreshold = (mLowerThreshold == Long.MAX_VALUE) ? price : Math.max(mLowerThreshold, price); } else { mUpperThreshold = (mUpperThreshold == Long.MIN_VALUE) ? price : Math.min(mUpperThreshold, price); } final long ctp = note.getStockLimitHonoringCtp(); if (ctp <= mRemainingConsumableCredits) { mCtpThreshold = Math.max(mCtpThreshold, ctp); } } } } /** * Returns the amount of time (in millisecond) it will take for the app to cross the next * lowest action affordability note (compared to its current balance) based on current * ongoing events. * Returns {@link #WILL_NOT_CROSS_THRESHOLD} if the app will never cross the lowest * threshold. */ long getTimeToCrossLowerThresholdMs() { if (mMaxDeltaPerSecToLowerThreshold == 0 && mMaxDeltaPerSecToCtpThreshold == 0) { // Will never cross lower threshold based on current events. return WILL_NOT_CROSS_THRESHOLD; } long minSeconds = Long.MAX_VALUE; if (mMaxDeltaPerSecToLowerThreshold != 0) { // deltaPerSec is a negative value, so do threshold-balance to cancel out the // negative. minSeconds = (mLowerThreshold - mCurBalance) / mMaxDeltaPerSecToLowerThreshold; } if (mMaxDeltaPerSecToCtpThreshold != 0) { minSeconds = Math.min(minSeconds, // deltaPerSec is a negative value, so do threshold-balance to cancel // out the negative. (mCtpThreshold - mRemainingConsumableCredits) / mMaxDeltaPerSecToCtpThreshold); } return minSeconds * 1000; } /** * Returns the amount of time (in millisecond) it will take for the app to cross the next * highest action affordability note (compared to its current balance) based on current * ongoing events. * Returns {@link #WILL_NOT_CROSS_THRESHOLD} if the app will never cross the upper * threshold. */ long getTimeToCrossUpperThresholdMs() { if (mMaxDeltaPerSecToUpperThreshold == 0) { // Will never cross upper threshold based on current events. return WILL_NOT_CROSS_THRESHOLD; } final long minSeconds = (mUpperThreshold - mCurBalance) / mMaxDeltaPerSecToUpperThreshold; return minSeconds * 1000; } @Override public void accept(OngoingEvent ongoingEvent) { final long deltaPerSec = ongoingEvent.getDeltaPerSec(); if (mCurBalance >= mLowerThreshold && deltaPerSec < 0) { mMaxDeltaPerSecToLowerThreshold += deltaPerSec; } else if (mCurBalance < mUpperThreshold && deltaPerSec > 0) { mMaxDeltaPerSecToUpperThreshold += deltaPerSec; } final long ctpPerSec = ongoingEvent.getCtpPerSec(); if (mRemainingConsumableCredits >= mCtpThreshold && deltaPerSec < 0) { mMaxDeltaPerSecToCtpThreshold -= ctpPerSec; } } } @GuardedBy("mLock") private final TrendCalculator mTrendCalculator = new TrendCalculator(); @GuardedBy("mLock") private void scheduleBalanceCheckLocked(final int userId, @NonNull final String pkgName) { SparseArrayMap ongoingEvents = mCurrentOngoingEvents.get(userId, pkgName); if (ongoingEvents == null || mIrs.isVip(userId, pkgName)) { // No ongoing transactions. No reason to schedule mBalanceThresholdAlarmQueue.removeAlarmForKey(UserPackage.of(userId, pkgName)); return; } mTrendCalculator.reset(getBalanceLocked(userId, pkgName), mScribe.getRemainingConsumableCakesLocked(), mActionAffordabilityNotes.get(userId, pkgName)); ongoingEvents.forEach(mTrendCalculator); final long lowerTimeMs = mTrendCalculator.getTimeToCrossLowerThresholdMs(); final long upperTimeMs = mTrendCalculator.getTimeToCrossUpperThresholdMs(); final long timeToThresholdMs; if (lowerTimeMs == TrendCalculator.WILL_NOT_CROSS_THRESHOLD) { if (upperTimeMs == TrendCalculator.WILL_NOT_CROSS_THRESHOLD) { // Will never cross a threshold based on current events. mBalanceThresholdAlarmQueue.removeAlarmForKey(UserPackage.of(userId, pkgName)); return; } timeToThresholdMs = upperTimeMs; } else { timeToThresholdMs = (upperTimeMs == TrendCalculator.WILL_NOT_CROSS_THRESHOLD) ? lowerTimeMs : Math.min(lowerTimeMs, upperTimeMs); } mBalanceThresholdAlarmQueue.addAlarm(UserPackage.of(userId, pkgName), SystemClock.elapsedRealtime() + timeToThresholdMs); } @GuardedBy("mLock") void tearDownLocked() { mCurrentOngoingEvents.clear(); mBalanceThresholdAlarmQueue.removeAllAlarms(); } @VisibleForTesting static class OngoingEvent { public final long startTimeElapsed; public final int eventId; @Nullable public final String tag; @Nullable public final EconomicPolicy.Reward reward; @Nullable public final EconomicPolicy.Cost actionCost; public int refCount; OngoingEvent(int eventId, @Nullable String tag, long startTimeElapsed, @NonNull EconomicPolicy.Reward reward) { this.startTimeElapsed = startTimeElapsed; this.eventId = eventId; this.tag = tag; this.reward = reward; this.actionCost = null; refCount = 1; } OngoingEvent(int eventId, @Nullable String tag, long startTimeElapsed, @NonNull EconomicPolicy.Cost actionCost) { this.startTimeElapsed = startTimeElapsed; this.eventId = eventId; this.tag = tag; this.reward = null; this.actionCost = actionCost; refCount = 1; } long getDeltaPerSec() { if (actionCost != null) { return -actionCost.price; } if (reward != null) { return reward.ongoingRewardPerSecond; } Slog.wtfStack(TAG, "No action or reward in ongoing event?!??!"); return 0; } long getCtpPerSec() { if (actionCost != null) { return actionCost.costToProduce; } return 0; } } private class OngoingEventUpdater implements Consumer { private int mUserId; private String mPkgName; private long mNow; private long mNowElapsed; private void reset(int userId, String pkgName, long now, long nowElapsed) { mUserId = userId; mPkgName = pkgName; mNow = now; mNowElapsed = nowElapsed; } @Override public void accept(OngoingEvent ongoingEvent) { // Disable balance check & affordability notifications here because // we're in the middle of updating ongoing action costs/prices and // sending out notifications or rescheduling the balance check alarm // would be a waste since we'll have to redo them again after all of // our internal state is updated. final boolean updateBalanceCheck = false; stopOngoingActionLocked(mUserId, mPkgName, ongoingEvent.eventId, ongoingEvent.tag, mNowElapsed, mNow, updateBalanceCheck, /* notifyOnAffordabilityChange */ false); noteOngoingEventLocked(mUserId, mPkgName, ongoingEvent.eventId, ongoingEvent.tag, mNowElapsed, updateBalanceCheck); } } private final OngoingEventUpdater mOngoingEventUpdater = new OngoingEventUpdater(); /** Track when apps will cross the closest affordability threshold (in both directions). */ private class BalanceThresholdAlarmQueue extends AlarmQueue { private BalanceThresholdAlarmQueue(Context context, Looper looper) { super(context, looper, ALARM_TAG_AFFORDABILITY_CHECK, "Affordability check", true, 15_000L); } @Override protected boolean isForUser(@NonNull UserPackage key, int userId) { return key.userId == userId; } @Override protected void processExpiredAlarms(@NonNull ArraySet expired) { for (int i = 0; i < expired.size(); ++i) { UserPackage p = expired.valueAt(i); mHandler.obtainMessage( MSG_CHECK_INDIVIDUAL_AFFORDABILITY, p.userId, 0, p.packageName) .sendToTarget(); } } } @GuardedBy("mLock") public void registerAffordabilityChangeListenerLocked(int userId, @NonNull String pkgName, @NonNull EconomyManagerInternal.AffordabilityChangeListener listener, @NonNull EconomyManagerInternal.ActionBill bill) { ArraySet actionAffordabilityNotes = mActionAffordabilityNotes.get(userId, pkgName); if (actionAffordabilityNotes == null) { actionAffordabilityNotes = new ArraySet<>(); mActionAffordabilityNotes.add(userId, pkgName, actionAffordabilityNotes); } final CompleteEconomicPolicy economicPolicy = mIrs.getCompleteEconomicPolicyLocked(); final ActionAffordabilityNote note = new ActionAffordabilityNote(bill, listener, economicPolicy); if (actionAffordabilityNotes.add(note)) { if (mIrs.getEnabledMode() == ENABLED_MODE_OFF) { // When TARE isn't enabled, we always say something is affordable. We also don't // want to silently drop affordability change listeners in case TARE becomes enabled // because then clients will be in an ambiguous state. note.setNewAffordability(true); return; } final boolean isVip = mIrs.isVip(userId, pkgName); note.recalculateCosts(economicPolicy, userId, pkgName); note.setNewAffordability(isVip || isAffordableLocked(getBalanceLocked(userId, pkgName), note.getCachedModifiedPrice(), note.getStockLimitHonoringCtp())); mIrs.postAffordabilityChanged(userId, pkgName, note); // Update ongoing alarm scheduleBalanceCheckLocked(userId, pkgName); } } @GuardedBy("mLock") public void unregisterAffordabilityChangeListenerLocked(int userId, @NonNull String pkgName, @NonNull EconomyManagerInternal.AffordabilityChangeListener listener, @NonNull EconomyManagerInternal.ActionBill bill) { final ArraySet actionAffordabilityNotes = mActionAffordabilityNotes.get(userId, pkgName); if (actionAffordabilityNotes != null) { final CompleteEconomicPolicy economicPolicy = mIrs.getCompleteEconomicPolicyLocked(); final ActionAffordabilityNote note = new ActionAffordabilityNote(bill, listener, economicPolicy); if (actionAffordabilityNotes.remove(note)) { // Update ongoing alarm scheduleBalanceCheckLocked(userId, pkgName); } } } static final class ActionAffordabilityNote { private final EconomyManagerInternal.ActionBill mActionBill; private final EconomyManagerInternal.AffordabilityChangeListener mListener; private long mStockLimitHonoringCtp; private long mModifiedPrice; private boolean mIsAffordable; @VisibleForTesting ActionAffordabilityNote(@NonNull EconomyManagerInternal.ActionBill bill, @NonNull EconomyManagerInternal.AffordabilityChangeListener listener, @NonNull EconomicPolicy economicPolicy) { mActionBill = bill; final List anticipatedActions = bill.getAnticipatedActions(); for (int i = 0; i < anticipatedActions.size(); ++i) { final EconomyManagerInternal.AnticipatedAction aa = anticipatedActions.get(i); final EconomicPolicy.Action action = economicPolicy.getAction(aa.actionId); if (action == null) { if ((aa.actionId & EconomicPolicy.ALL_POLICIES) == 0) { throw new IllegalArgumentException("Invalid action id: " + aa.actionId); } else { Slog.w(TAG, "Tracking disabled policy's action? " + aa.actionId); } } } mListener = listener; } @NonNull EconomyManagerInternal.ActionBill getActionBill() { return mActionBill; } @NonNull EconomyManagerInternal.AffordabilityChangeListener getListener() { return mListener; } private long getCachedModifiedPrice() { return mModifiedPrice; } /** Returns the cumulative CTP of actions in this note that respect the stock limit. */ private long getStockLimitHonoringCtp() { return mStockLimitHonoringCtp; } @VisibleForTesting void recalculateCosts(@NonNull EconomicPolicy economicPolicy, int userId, @NonNull String pkgName) { long modifiedPrice = 0; long stockLimitHonoringCtp = 0; final List anticipatedActions = mActionBill.getAnticipatedActions(); for (int i = 0; i < anticipatedActions.size(); ++i) { final EconomyManagerInternal.AnticipatedAction aa = anticipatedActions.get(i); final EconomicPolicy.Action action = economicPolicy.getAction(aa.actionId); final EconomicPolicy.Cost actionCost = economicPolicy.getCostOfAction(aa.actionId, userId, pkgName); modifiedPrice += actionCost.price * aa.numInstantaneousCalls + actionCost.price * (aa.ongoingDurationMs / 1000); if (action.respectsStockLimit) { stockLimitHonoringCtp += actionCost.costToProduce * aa.numInstantaneousCalls + actionCost.costToProduce * (aa.ongoingDurationMs / 1000); } } mModifiedPrice = modifiedPrice; mStockLimitHonoringCtp = stockLimitHonoringCtp; } boolean isCurrentlyAffordable() { return mIsAffordable; } private void setNewAffordability(boolean isAffordable) { mIsAffordable = isAffordable; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ActionAffordabilityNote)) return false; ActionAffordabilityNote other = (ActionAffordabilityNote) o; return mActionBill.equals(other.mActionBill) && mListener.equals(other.mListener); } @Override public int hashCode() { int hash = 0; hash = 31 * hash + Objects.hash(mListener); hash = 31 * hash + mActionBill.hashCode(); return hash; } } private final class AgentHandler extends Handler { AgentHandler(Looper looper) { super(looper); } @Override public void handleMessage(Message msg) { switch (msg.what) { case MSG_CHECK_ALL_AFFORDABILITY: { synchronized (mLock) { removeMessages(MSG_CHECK_ALL_AFFORDABILITY); onAnythingChangedLocked(false); } } break; case MSG_CHECK_INDIVIDUAL_AFFORDABILITY: { final int userId = msg.arg1; final String pkgName = (String) msg.obj; synchronized (mLock) { final ArraySet actionAffordabilityNotes = mActionAffordabilityNotes.get(userId, pkgName); if (actionAffordabilityNotes != null && actionAffordabilityNotes.size() > 0) { final long newBalance = getBalanceLocked(userId, pkgName); final boolean isVip = mIrs.isVip(userId, pkgName); for (int i = 0; i < actionAffordabilityNotes.size(); ++i) { final ActionAffordabilityNote note = actionAffordabilityNotes.valueAt(i); final boolean isAffordable = isVip || isAffordableLocked( newBalance, note.getCachedModifiedPrice(), note.getStockLimitHonoringCtp()); if (note.isCurrentlyAffordable() != isAffordable) { note.setNewAffordability(isAffordable); mIrs.postAffordabilityChanged(userId, pkgName, note); } } } scheduleBalanceCheckLocked(userId, pkgName); } } break; } } } @GuardedBy("mLock") void dumpLocked(IndentingPrintWriter pw) { mBalanceThresholdAlarmQueue.dump(pw); pw.println(); pw.println("Ongoing events:"); pw.increaseIndent(); boolean printedEvents = false; final long nowElapsed = SystemClock.elapsedRealtime(); for (int u = mCurrentOngoingEvents.numMaps() - 1; u >= 0; --u) { final int userId = mCurrentOngoingEvents.keyAt(u); for (int p = mCurrentOngoingEvents.numElementsForKey(userId) - 1; p >= 0; --p) { final String pkgName = mCurrentOngoingEvents.keyAt(u, p); final SparseArrayMap ongoingEvents = mCurrentOngoingEvents.get(userId, pkgName); boolean printedApp = false; for (int e = ongoingEvents.numMaps() - 1; e >= 0; --e) { final int eventId = ongoingEvents.keyAt(e); for (int t = ongoingEvents.numElementsForKey(eventId) - 1; t >= 0; --t) { if (!printedApp) { printedApp = true; pw.println(appToString(userId, pkgName)); pw.increaseIndent(); } printedEvents = true; OngoingEvent ongoingEvent = ongoingEvents.valueAt(e, t); pw.print(EconomicPolicy.eventToString(ongoingEvent.eventId)); if (ongoingEvent.tag != null) { pw.print("("); pw.print(ongoingEvent.tag); pw.print(")"); } pw.print(" runtime="); TimeUtils.formatDuration(nowElapsed - ongoingEvent.startTimeElapsed, pw); pw.print(" delta/sec="); pw.print(cakeToString(ongoingEvent.getDeltaPerSec())); final long ctp = ongoingEvent.getCtpPerSec(); if (ctp != 0) { pw.print(" ctp/sec="); pw.print(cakeToString(ongoingEvent.getCtpPerSec())); } pw.print(" refCount="); pw.print(ongoingEvent.refCount); pw.println(); } } if (printedApp) { pw.decreaseIndent(); } } } if (!printedEvents) { pw.print("N/A"); } pw.decreaseIndent(); } }