blob: dad3a784cfb947de509fbf8ca800bf11fca7762e [file] [log] [blame]
/*
* 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 android.annotation.MainThread;
import android.app.usage.CacheQuotaHint;
import android.app.usage.CacheQuotaService;
import android.app.usage.ICacheQuotaService;
import android.app.usage.UsageStats;
import android.app.usage.UsageStatsManager;
import android.app.usage.UsageStatsManagerInternal;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
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.StatFs;
import android.os.UserHandle;
import android.os.UserManager;
import android.text.format.DateUtils;
import android.util.ArrayMap;
import android.util.AtomicFile;
import android.util.Pair;
import android.util.Slog;
import android.util.SparseLongArray;
import android.util.Xml;
import com.android.internal.annotations.VisibleForTesting;
import com.android.modules.utils.TypedXmlPullParser;
import com.android.modules.utils.TypedXmlSerializer;
import com.android.server.pm.Installer;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
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.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* CacheQuotaStrategy is a strategy for determining cache quotas using usage stats and foreground
* time using the calculation as defined in the refuel rocket.
*/
public class CacheQuotaStrategy implements RemoteCallback.OnResultListener {
private static final String TAG = "CacheQuotaStrategy";
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 final ArrayMap<String, SparseLongArray> mQuotaMap;
private ServiceConnection mServiceConnection;
private ICacheQuotaService mRemoteService;
private AtomicFile mPreviousValuesFile;
public CacheQuotaStrategy(
Context context, UsageStatsManagerInternal usageStatsManager, Installer installer,
ArrayMap<String, SparseLongArray> quotaMap) {
mContext = Objects.requireNonNull(context);
mUsageStats = Objects.requireNonNull(usageStatsManager);
mInstaller = Objects.requireNonNull(installer);
mQuotaMap = Objects.requireNonNull(quotaMap);
mPreviousValuesFile = new AtomicFile(new File(
new File(Environment.getDataDirectory(), "system"), "cachequota.xml"));
}
/**
* Recalculates the quotas and stores them to installd.
*/
public void recalculateQuotas() {
createServiceConnection();
ComponentName component = getServiceComponentName();
if (component != null) {
Intent intent = new Intent();
intent.setComponent(component);
mContext.bindServiceAsUser(
intent, mServiceConnection, Context.BIND_AUTO_CREATE, UserHandle.CURRENT);
}
}
private void createServiceConnection() {
// If we're already connected, don't create a new connection.
if (mServiceConnection != null) {
return;
}
mServiceConnection = new ServiceConnection() {
@Override
@MainThread
public void onServiceConnected(ComponentName name, IBinder service) {
Runnable runnable = new Runnable() {
@Override
public void run() {
synchronized (mLock) {
mRemoteService = ICacheQuotaService.Stub.asInterface(service);
List<CacheQuotaHint> requests = getUnfulfilledRequests();
final RemoteCallback remoteCallback =
new RemoteCallback(CacheQuotaStrategy.this);
try {
mRemoteService.computeCacheQuotaHints(remoteCallback, requests);
} catch (RemoteException ex) {
Slog.w(TAG,
"Remote exception occurred while trying to get cache quota",
ex);
}
}
}
};
AsyncTask.execute(runnable);
}
@Override
@MainThread
public void onServiceDisconnected(ComponentName name) {
synchronized (mLock) {
mRemoteService = null;
}
}
};
}
/**
* 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<CacheQuotaHint> getUnfulfilledRequests() {
long timeNow = System.currentTimeMillis();
long oneYearAgo = timeNow - DateUtils.YEAR_IN_MILLIS;
List<CacheQuotaHint> requests = new ArrayList<>();
UserManager um = mContext.getSystemService(UserManager.class);
final List<UserInfo> users = um.getUsers();
final PackageManager packageManager = mContext.getPackageManager();
for (UserInfo info : users) {
List<UsageStats> stats =
mUsageStats.queryUsageStatsForUser(info.id, UsageStatsManager.INTERVAL_BEST,
oneYearAgo, timeNow, /*obfuscateInstantApps=*/ false);
if (stats == null) {
continue;
}
for (int i = 0; i < stats.size(); ++i) {
UsageStats stat = stats.get(i);
String packageName = stat.getPackageName();
try {
// We need the app info to determine the uid and the uuid of the volume
// where the app is installed.
ApplicationInfo appInfo = packageManager.getApplicationInfoAsUser(
packageName, 0, info.id);
requests.add(
new CacheQuotaHint.Builder()
.setVolumeUuid(appInfo.volumeUuid)
.setUid(appInfo.uid)
.setUsageStats(stat)
.setQuota(CacheQuotaHint.QUOTA_NOT_SET)
.build());
} catch (PackageManager.NameNotFoundException e) {
// This may happen if an app has a recorded usage, but has been uninstalled.
continue;
}
}
}
return requests;
}
@Override
public void onResult(Bundle data) {
final List<CacheQuotaHint> processedRequests =
data.getParcelableArrayList(
CacheQuotaService.REQUEST_LIST_KEY, android.app.usage.CacheQuotaHint.class);
pushProcessedQuotas(processedRequests);
writeXmlToFile(processedRequests);
}
private void pushProcessedQuotas(List<CacheQuotaHint> processedRequests) {
for (CacheQuotaHint request : processedRequests) {
long proposedQuota = request.getQuota();
if (proposedQuota == CacheQuotaHint.QUOTA_NOT_SET) {
continue;
}
try {
int uid = request.getUid();
mInstaller.setAppQuota(request.getVolumeUuid(),
UserHandle.getUserId(uid),
UserHandle.getAppId(uid), proposedQuota);
insertIntoQuotaMap(request.getVolumeUuid(),
UserHandle.getUserId(uid),
UserHandle.getAppId(uid), proposedQuota);
} catch (Installer.InstallerException ex) {
Slog.w(TAG,
"Failed to set cache quota for " + request.getUid(),
ex);
}
}
disconnectService();
}
private void insertIntoQuotaMap(String volumeUuid, int userId, int appId, long quota) {
SparseLongArray volumeMap = mQuotaMap.get(volumeUuid);
if (volumeMap == null) {
volumeMap = new SparseLongArray();
mQuotaMap.put(volumeUuid, volumeMap);
}
volumeMap.put(UserHandle.getUid(userId, appId), quota);
}
private void disconnectService() {
if (mServiceConnection != null) {
mContext.unbindService(mServiceConnection);
mServiceConnection = null;
}
}
private ComponentName getServiceComponentName() {
String packageName =
mContext.getPackageManager().getServicesSystemSharedLibraryPackageName();
if (packageName == null) {
Slog.w(TAG, "could not access the cache quota service: no package!");
return null;
}
Intent intent = new Intent(CacheQuotaService.SERVICE_INTERFACE);
intent.setPackage(packageName);
ResolveInfo resolveInfo = mContext.getPackageManager().resolveService(intent,
PackageManager.GET_SERVICES | PackageManager.GET_META_DATA);
if (resolveInfo == null || resolveInfo.serviceInfo == null) {
Slog.w(TAG, "No valid components found.");
return null;
}
ServiceInfo serviceInfo = resolveInfo.serviceInfo;
return new ComponentName(serviceInfo.packageName, serviceInfo.name);
}
private void writeXmlToFile(List<CacheQuotaHint> processedRequests) {
FileOutputStream fileStream = null;
try {
fileStream = mPreviousValuesFile.startWrite();
TypedXmlSerializer out = Xml.resolveSerializer(fileStream);
final StatFs stats = new StatFs(Environment.getDataDirectory().getAbsolutePath());
saveToXml(out, processedRequests, stats.getAvailableBytes());
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 {
Pair<Long, List<CacheQuotaHint>> cachedValues = null;
try (FileInputStream stream = mPreviousValuesFile.openRead()) {
try {
cachedValues = readFromXml(stream);
} catch (XmlPullParserException e) {
throw new IllegalStateException(e.getMessage());
}
} catch (FileNotFoundException e) {
// The file may not exist yet -- this isn't truly exceptional.
return -1;
}
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(TypedXmlSerializer out,
List<CacheQuotaHint> requests, long bytesWhenCalculated) throws IOException {
out.startDocument(null, true);
out.startTag(null, CACHE_INFO_TAG);
out.attributeLong(null, ATTR_PREVIOUS_BYTES, bytesWhenCalculated);
for (CacheQuotaHint request : requests) {
out.startTag(null, TAG_QUOTA);
String uuid = request.getVolumeUuid();
if (uuid != null) {
out.attribute(null, ATTR_UUID, request.getVolumeUuid());
}
out.attributeInt(null, ATTR_UID, request.getUid());
out.attributeLong(null, ATTR_QUOTA_IN_BYTES, request.getQuota());
out.endTag(null, TAG_QUOTA);
}
out.endTag(null, CACHE_INFO_TAG);
out.endDocument();
}
protected static Pair<Long, List<CacheQuotaHint>> readFromXml(InputStream inputStream)
throws XmlPullParserException, IOException {
TypedXmlPullParser parser = Xml.resolvePullParser(inputStream);
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<CacheQuotaHint> quotas = new ArrayList<>();
long previousBytes;
try {
previousBytes = parser.getAttributeLong(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(TypedXmlPullParser parser) {
try {
String uuid = parser.getAttributeValue(null, ATTR_UUID);
int uid = parser.getAttributeInt(null, ATTR_UID);
long bytes = parser.getAttributeLong(null, ATTR_QUOTA_IN_BYTES);
return new CacheQuotaHint.Builder()
.setVolumeUuid(uuid).setUid(uid).setQuota(bytes).build();
} catch (XmlPullParserException e) {
Slog.e(TAG, "Invalid cache quota request, skipping.");
return null;
}
}
}