From e40da3c1b7d4eb810b6f067075dbbb011d02a379 Mon Sep 17 00:00:00 2001 From: Daniel Nishi Date: Mon, 13 Feb 2017 17:19:43 -0800 Subject: [PATCH] Save/load calculated cache quotas to a file. This will prevent us from unnecessarily redoing calculation work by loading the last caches on boot and shoving them down to installd. Bug: 33965858 Test: Framework services tests Change-Id: Ie94e269aa72bceb1ebe87911eaa42e2d826c1123 --- .../android/app/usage/CacheQuotaHint.java | 24 ++- .../server/storage/CacheQuotaStrategy.java | 169 +++++++++++++++++- .../storage/CacheQuotaStrategyTest.java | 128 +++++++++++++ .../server/usage/StorageStatsService.java | 39 +++- 4 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 services/tests/servicestests/src/com/android/server/storage/CacheQuotaStrategyTest.java diff --git a/core/java/android/app/usage/CacheQuotaHint.java b/core/java/android/app/usage/CacheQuotaHint.java index 4b6f99b43a09b..1d5c2b05488ee 100644 --- a/core/java/android/app/usage/CacheQuotaHint.java +++ b/core/java/android/app/usage/CacheQuotaHint.java @@ -24,8 +24,10 @@ import android.os.Parcelable; import com.android.internal.util.Preconditions; +import java.util.Objects; + /** - * CacheQuotaRequest represents a triplet of a uid, the volume UUID it is stored upon, and + * CacheQuotaHint represents a triplet of a uid, the volume UUID it is stored upon, and * its usage stats. When processed, it obtains a cache quota as defined by the system which * allows apps to understand how much cache to use. * {@hide} @@ -78,6 +80,23 @@ public final class CacheQuotaHint implements Parcelable { return 0; } + @Override + public boolean equals(Object o) { + if (o instanceof CacheQuotaHint) { + final CacheQuotaHint other = (CacheQuotaHint) o; + return Objects.equals(mUuid, other.mUuid) + && Objects.equals(mUsageStats, other.mUsageStats) + && mUid == other.mUid && mQuota == other.mQuota; + } + + return false; + } + + @Override + public int hashCode() { + return Objects.hash(this.mUuid, this.mUid, this.mUsageStats, this.mQuota); + } + public static final class Builder { private String mUuid; private int mUid; @@ -100,7 +119,7 @@ public final class CacheQuotaHint implements Parcelable { } public @NonNull Builder setUid(int uid) { - Preconditions.checkArgumentPositive(uid, "Proposed uid was not positive."); + Preconditions.checkArgumentNonnegative(uid, "Proposed uid was negative."); mUid = uid; return this; } @@ -117,7 +136,6 @@ public final class CacheQuotaHint implements Parcelable { } public @NonNull CacheQuotaHint build() { - Preconditions.checkNotNull(mUsageStats); return new CacheQuotaHint(this); } } diff --git a/services/core/java/com/android/server/storage/CacheQuotaStrategy.java b/services/core/java/com/android/server/storage/CacheQuotaStrategy.java index 10d30aae86ce3..c06439278954f 100644 --- a/services/core/java/com/android/server/storage/CacheQuotaStrategy.java +++ b/services/core/java/com/android/server/storage/CacheQuotaStrategy.java @@ -34,21 +34,37 @@ import android.content.pm.ServiceInfo; import android.content.pm.UserInfo; import android.os.AsyncTask; import android.os.Bundle; +import android.os.Environment; import android.os.IBinder; import android.os.RemoteCallback; import android.os.RemoteException; import android.os.UserHandle; import android.os.UserManager; import android.text.format.DateUtils; +import android.util.Pair; import android.util.Slog; +import android.util.Xml; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.os.AtomicFile; +import com.android.internal.util.FastXmlSerializer; import com.android.internal.util.Preconditions; import com.android.server.pm.Installer; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlSerializer; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; - /** * CacheQuotaStrategy is a strategy for determining cache quotas using usage stats and foreground * time using the calculation as defined in the refuel rocket. @@ -58,17 +74,28 @@ public class CacheQuotaStrategy implements RemoteCallback.OnResultListener { private final Object mLock = new Object(); + // XML Constants + private static final String CACHE_INFO_TAG = "cache-info"; + private static final String ATTR_PREVIOUS_BYTES = "previousBytes"; + private static final String TAG_QUOTA = "quota"; + private static final String ATTR_UUID = "uuid"; + private static final String ATTR_UID = "uid"; + private static final String ATTR_QUOTA_IN_BYTES = "bytes"; + private final Context mContext; private final UsageStatsManagerInternal mUsageStats; private final Installer mInstaller; private ServiceConnection mServiceConnection; private ICacheQuotaService mRemoteService; + private AtomicFile mPreviousValuesFile; public CacheQuotaStrategy( Context context, UsageStatsManagerInternal usageStatsManager, Installer installer) { mContext = Preconditions.checkNotNull(context); mUsageStats = Preconditions.checkNotNull(usageStatsManager); mInstaller = Preconditions.checkNotNull(installer); + mPreviousValuesFile = new AtomicFile(new File( + new File(Environment.getDataDirectory(), "system"), "cachequota.xml")); } /** @@ -128,7 +155,7 @@ public class CacheQuotaStrategy implements RemoteCallback.OnResultListener { } /** - * Returns a list of CacheQuotaRequests which do not have their quotas filled out for apps + * Returns a list of CacheQuotaHints which do not have their quotas filled out for apps * which have been used in the last year. */ private List getUnfulfilledRequests() { @@ -176,6 +203,11 @@ public class CacheQuotaStrategy implements RemoteCallback.OnResultListener { final List processedRequests = data.getParcelableArrayList( CacheQuotaService.REQUEST_LIST_KEY); + pushProcessedQuotas(processedRequests); + writeXmlToFile(processedRequests); + } + + private void pushProcessedQuotas(List processedRequests) { final int requestSize = processedRequests.size(); for (int i = 0; i < requestSize; i++) { CacheQuotaHint request = processedRequests.get(i); @@ -200,8 +232,10 @@ public class CacheQuotaStrategy implements RemoteCallback.OnResultListener { } private void disconnectService() { - mContext.unbindService(mServiceConnection); - mServiceConnection = null; + if (mServiceConnection != null) { + mContext.unbindService(mServiceConnection); + mServiceConnection = null; + } } private ComponentName getServiceComponentName() { @@ -223,4 +257,131 @@ public class CacheQuotaStrategy implements RemoteCallback.OnResultListener { ServiceInfo serviceInfo = resolveInfo.serviceInfo; return new ComponentName(serviceInfo.packageName, serviceInfo.name); } + + private void writeXmlToFile(List processedRequests) { + FileOutputStream fileStream = null; + try { + XmlSerializer out = new FastXmlSerializer(); + fileStream = mPreviousValuesFile.startWrite(); + out.setOutput(fileStream, StandardCharsets.UTF_8.name()); + saveToXml(out, processedRequests, 0); + mPreviousValuesFile.finishWrite(fileStream); + } catch (Exception e) { + Slog.e(TAG, "An error occurred while writing the cache quota file.", e); + mPreviousValuesFile.failWrite(fileStream); + } + } + + /** + * Initializes the quotas from the file. + * @return the number of bytes that were free on the device when the quotas were last calced. + */ + public long setupQuotasFromFile() throws IOException { + FileInputStream stream; + try { + stream = mPreviousValuesFile.openRead(); + } catch (FileNotFoundException e) { + // The file may not exist yet -- this isn't truly exceptional. + return -1; + } + + Pair> cachedValues = null; + try { + cachedValues = readFromXml(stream); + } catch (XmlPullParserException e) { + throw new IllegalStateException(e.getMessage()); + } + + if (cachedValues == null) { + Slog.e(TAG, "An error occurred while parsing the cache quota file."); + return -1; + } + pushProcessedQuotas(cachedValues.second); + return cachedValues.first; + } + + @VisibleForTesting + static void saveToXml(XmlSerializer out, + List requests, long bytesWhenCalculated) throws IOException { + out.startDocument(null, true); + out.startTag(null, CACHE_INFO_TAG); + int requestSize = requests.size(); + out.attribute(null, ATTR_PREVIOUS_BYTES, Long.toString(bytesWhenCalculated)); + + for (int i = 0; i < requestSize; i++) { + CacheQuotaHint request = requests.get(i); + out.startTag(null, TAG_QUOTA); + String uuid = request.getVolumeUuid(); + if (uuid != null) { + out.attribute(null, ATTR_UUID, request.getVolumeUuid()); + } + out.attribute(null, ATTR_UID, Integer.toString(request.getUid())); + out.attribute(null, ATTR_QUOTA_IN_BYTES, Long.toString(request.getQuota())); + out.endTag(null, TAG_QUOTA); + } + out.endTag(null, CACHE_INFO_TAG); + out.endDocument(); + } + + protected static Pair> readFromXml(InputStream inputStream) + throws XmlPullParserException, IOException { + XmlPullParser parser = Xml.newPullParser(); + parser.setInput(inputStream, StandardCharsets.UTF_8.name()); + + int eventType = parser.getEventType(); + while (eventType != XmlPullParser.START_TAG && + eventType != XmlPullParser.END_DOCUMENT) { + eventType = parser.next(); + } + + if (eventType == XmlPullParser.END_DOCUMENT) { + Slog.d(TAG, "No quotas found in quota file."); + return null; + } + + String tagName = parser.getName(); + if (!CACHE_INFO_TAG.equals(tagName)) { + throw new IllegalStateException("Invalid starting tag."); + } + + final List quotas = new ArrayList<>(); + long previousBytes; + try { + previousBytes = Long.parseLong(parser.getAttributeValue( + null, ATTR_PREVIOUS_BYTES)); + } catch (NumberFormatException e) { + throw new IllegalStateException( + "Previous bytes formatted incorrectly; aborting quota read."); + } + + eventType = parser.next(); + do { + if (eventType == XmlPullParser.START_TAG) { + tagName = parser.getName(); + if (TAG_QUOTA.equals(tagName)) { + CacheQuotaHint request = getRequestFromXml(parser); + if (request == null) { + continue; + } + quotas.add(request); + } + } + eventType = parser.next(); + } while (eventType != XmlPullParser.END_DOCUMENT); + return new Pair<>(previousBytes, quotas); + } + + @VisibleForTesting + static CacheQuotaHint getRequestFromXml(XmlPullParser parser) { + try { + String uuid = parser.getAttributeValue(null, ATTR_UUID); + int uid = Integer.parseInt(parser.getAttributeValue(null, ATTR_UID)); + long bytes = Long.parseLong(parser.getAttributeValue(null, ATTR_QUOTA_IN_BYTES)); + return new CacheQuotaHint.Builder() + .setVolumeUuid(uuid).setUid(uid).setQuota(bytes).build(); + } catch (NumberFormatException e) { + Slog.e(TAG, "Invalid cache quota request, skipping."); + return null; + } + } } diff --git a/services/tests/servicestests/src/com/android/server/storage/CacheQuotaStrategyTest.java b/services/tests/servicestests/src/com/android/server/storage/CacheQuotaStrategyTest.java new file mode 100644 index 0000000000000..1d62e01c068d4 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/storage/CacheQuotaStrategyTest.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2017 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.storage; + +import static com.google.common.truth.Truth.assertThat; + +import android.app.usage.CacheQuotaHint; +import android.test.AndroidTestCase; +import android.util.Pair; + +import com.android.internal.util.FastXmlSerializer; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.io.ByteArrayInputStream; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; + +@RunWith(JUnit4.class) +public class CacheQuotaStrategyTest extends AndroidTestCase { + StringWriter mWriter; + FastXmlSerializer mOut; + + @Before + public void setUp() throws Exception { + mWriter = new StringWriter(); + mOut = new FastXmlSerializer(); + mOut.setOutput(mWriter); + } + + @Test + public void testEmptyWrite() throws Exception { + CacheQuotaStrategy.saveToXml(mOut, new ArrayList<>(), 0); + mOut.flush(); + + assertThat(mWriter.toString()).isEqualTo( + "\n" + + "\n"); + } + + @Test + public void testWriteOneQuota() throws Exception { + ArrayList requests = new ArrayList<>(); + requests.add(buildCacheQuotaHint("uuid", 0, 100)); + + CacheQuotaStrategy.saveToXml(mOut, requests, 1000); + mOut.flush(); + + assertThat(mWriter.toString()).isEqualTo( + "\n" + + "\n" + + "\n" + + "\n"); + } + + @Test + public void testWriteMultipleQuotas() throws Exception { + ArrayList requests = new ArrayList<>(); + requests.add(buildCacheQuotaHint("uuid", 0, 100)); + requests.add(buildCacheQuotaHint("uuid2", 10, 250)); + + CacheQuotaStrategy.saveToXml(mOut, requests, 1000); + mOut.flush(); + + assertThat(mWriter.toString()).isEqualTo( + "\n" + + "\n" + + "\n" + + "\n" + + "\n"); + } + + @Test + public void testNullUuidDoesntCauseCrash() throws Exception { + ArrayList requests = new ArrayList<>(); + requests.add(buildCacheQuotaHint(null, 0, 100)); + requests.add(buildCacheQuotaHint(null, 10, 250)); + + CacheQuotaStrategy.saveToXml(mOut, requests, 1000); + mOut.flush(); + + assertThat(mWriter.toString()).isEqualTo( + "\n" + + "\n" + + "\n" + + "\n" + + "\n"); + } + + @Test + public void testReadMultipleQuotas() throws Exception { + String input = "\n" + + "\n" + + "\n" + + "\n" + + "\n"; + + Pair> output = + CacheQuotaStrategy.readFromXml(new ByteArrayInputStream(input.getBytes("UTF-8"))); + + assertThat(output.first).isEqualTo(1000); + assertThat(output.second).containsExactly(buildCacheQuotaHint("uuid", 0, 100), + buildCacheQuotaHint("uuid2", 10, 250)); + } + + private CacheQuotaHint buildCacheQuotaHint(String volumeUuid, int uid, long quota) { + return new CacheQuotaHint.Builder() + .setVolumeUuid(volumeUuid).setUid(uid).setQuota(quota).build(); + } +} \ No newline at end of file diff --git a/services/usage/java/com/android/server/usage/StorageStatsService.java b/services/usage/java/com/android/server/usage/StorageStatsService.java index ed1530a4eddb2..89e68a63ac3b1 100644 --- a/services/usage/java/com/android/server/usage/StorageStatsService.java +++ b/services/usage/java/com/android/server/usage/StorageStatsService.java @@ -55,6 +55,8 @@ import com.android.server.pm.Installer; import com.android.server.pm.Installer.InstallerException; import com.android.server.storage.CacheQuotaStrategy; +import java.io.IOException; + public class StorageStatsService extends IStorageStatsManager.Stub { private static final String TAG = "StorageStatsService"; @@ -97,7 +99,7 @@ public class StorageStatsService extends IStorageStatsManager.Stub { invalidateMounts(); mHandler = new H(IoThread.get().getLooper()); - mHandler.sendEmptyMessageDelayed(H.MSG_CHECK_STORAGE_DELTA, DELAY_IN_MILLIS); + mHandler.sendEmptyMessageDelayed(H.MSG_LOAD_CACHED_QUOTAS_FROM_FILE, DELAY_IN_MILLIS); mStorage.registerListener(new StorageEventListener() { @Override @@ -343,12 +345,14 @@ public class StorageStatsService extends IStorageStatsManager.Stub { private class H extends Handler { private static final int MSG_CHECK_STORAGE_DELTA = 100; + private static final int MSG_LOAD_CACHED_QUOTAS_FROM_FILE = 101; /** * By only triggering a re-calculation after the storage has changed sizes, we can avoid * recalculating quotas too often. Minimum change delta defines the percentage of change * we need to see before we recalculate. */ private static final double MINIMUM_CHANGE_DELTA = 0.05; + private static final int UNSET = -1; private static final boolean DEBUG = false; private final StatFs mStats; @@ -361,7 +365,6 @@ public class StorageStatsService extends IStorageStatsManager.Stub { mStats = new StatFs(Environment.getDataDirectory().getAbsolutePath()); mPreviousBytes = mStats.getFreeBytes(); mMinimumThresholdBytes = mStats.getTotalBytes() * MINIMUM_CHANGE_DELTA; - // TODO: Load cache quotas from a file to avoid re-doing work. } public void handleMessage(Message msg) { @@ -378,7 +381,26 @@ public class StorageStatsService extends IStorageStatsManager.Stub { long bytesDelta = Math.abs(mPreviousBytes - mStats.getFreeBytes()); if (bytesDelta > mMinimumThresholdBytes) { mPreviousBytes = mStats.getFreeBytes(); - recalculateQuotas(); + recalculateQuotas(getInitializedStrategy()); + } + sendEmptyMessageDelayed(MSG_CHECK_STORAGE_DELTA, DELAY_IN_MILLIS); + break; + } + case MSG_LOAD_CACHED_QUOTAS_FROM_FILE: { + CacheQuotaStrategy strategy = getInitializedStrategy(); + mPreviousBytes = UNSET; + try { + mPreviousBytes = strategy.setupQuotasFromFile(); + } catch (IOException e) { + Slog.e(TAG, "An error occurred while reading the cache quota file.", e); + } catch (IllegalStateException e) { + Slog.e(TAG, "Cache quota XML file is malformed?", e); + } + + // If errors occurred getting the quotas from disk, let's re-calc them. + if (mPreviousBytes < 0) { + mPreviousBytes = mStats.getFreeBytes(); + recalculateQuotas(strategy); } sendEmptyMessageDelayed(MSG_CHECK_STORAGE_DELTA, DELAY_IN_MILLIS); break; @@ -391,17 +413,18 @@ public class StorageStatsService extends IStorageStatsManager.Stub { } } - private void recalculateQuotas() { + private void recalculateQuotas(CacheQuotaStrategy strategy) { if (DEBUG) { Slog.v(TAG, ">>> recalculating quotas "); } + strategy.recalculateQuotas(); + } + + private CacheQuotaStrategy getInitializedStrategy() { UsageStatsManagerInternal usageStatsManager = LocalServices.getService(UsageStatsManagerInternal.class); - CacheQuotaStrategy strategy = new CacheQuotaStrategy( - mContext, usageStatsManager, mInstaller); - // TODO: Save cache quotas to an XML file. - strategy.recalculateQuotas(); + return new CacheQuotaStrategy(mContext, usageStatsManager, mInstaller); } }