/*
|
* 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.UserHandle;
|
import android.os.UserManager;
|
import android.text.format.DateUtils;
|
import android.util.ArrayMap;
|
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.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.
|
*/
|
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 = Preconditions.checkNotNull(context);
|
mUsageStats = Preconditions.checkNotNull(usageStatsManager);
|
mInstaller = Preconditions.checkNotNull(installer);
|
mQuotaMap = Preconditions.checkNotNull(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 int userCount = users.size();
|
final PackageManager packageManager = mContext.getPackageManager();
|
for (int i = 0; i < userCount; i++) {
|
UserInfo info = users.get(i);
|
List<UsageStats> stats =
|
mUsageStats.queryUsageStatsForUser(info.id, UsageStatsManager.INTERVAL_BEST,
|
oneYearAgo, timeNow, /*obfuscateInstantApps=*/ false);
|
if (stats == null) {
|
continue;
|
}
|
|
for (UsageStats stat : stats) {
|
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);
|
pushProcessedQuotas(processedRequests);
|
writeXmlToFile(processedRequests);
|
}
|
|
private void pushProcessedQuotas(List<CacheQuotaHint> processedRequests) {
|
final int requestSize = processedRequests.size();
|
for (int i = 0; i < requestSize; i++) {
|
CacheQuotaHint request = processedRequests.get(i);
|
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 {
|
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 {
|
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(XmlSerializer out,
|
List<CacheQuotaHint> 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<Long, List<CacheQuotaHint>> 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<CacheQuotaHint> 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;
|
}
|
}
|
}
|