blob: 0b99ff64351ba7740773356673e4072258cf7504 [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.storagemanager.deletionhelper;
import android.app.usage.UsageStats;
import android.app.usage.UsageStatsManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.graphics.drawable.Drawable;
import android.os.SystemProperties;
import android.os.UserHandle;
import androidx.annotation.VisibleForTesting;
import android.text.format.DateUtils;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;
import com.android.settingslib.applications.StorageStatsSource;
import com.android.settingslib.applications.StorageStatsSource.AppStorageStats;
import com.android.storagemanager.deletionhelper.AppsAsyncLoader.PackageInfo;
import com.android.storagemanager.utils.AsyncLoader;
import java.io.IOException;
import java.text.Collator;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.Collections;
import java.util.stream.Collectors;
/**
* AppsAsyncLoader is a Loader which loads app storage information and categories it by the app's
* specified categorization.
*/
public class AppsAsyncLoader extends AsyncLoader<List<PackageInfo>> {
private static final String TAG = "AppsAsyncLoader";
public static final long NEVER_USED = Long.MAX_VALUE;
public static final long UNKNOWN_LAST_USE = -1;
public static final long UNUSED_DAYS_DELETION_THRESHOLD = 90;
public static final long MIN_DELETION_THRESHOLD = Long.MIN_VALUE;
public static final int NORMAL_THRESHOLD = 0;
public static final int SIZE_UNKNOWN = -1;
public static final int SIZE_INVALID = -2;
public static final int NO_THRESHOLD = 1;
private static final String DEBUG_APP_UNUSED_OVERRIDE = "debug.asm.app_unused_limit";
private static final long DAYS_IN_A_TYPICAL_YEAR = 365;
protected Clock mClock;
protected AppsAsyncLoader.AppFilter mFilter;
private int mUserId;
private String mUuid;
private StorageStatsSource mStatsManager;
private PackageManager mPackageManager;
private UsageStatsManager mUsageStatsManager;
private AppsAsyncLoader(
Context context,
int userId,
String uuid,
StorageStatsSource source,
PackageManager pm,
UsageStatsManager um,
AppsAsyncLoader.AppFilter filter) {
super(context);
mUserId = userId;
mUuid = uuid;
mStatsManager = source;
mPackageManager = pm;
mUsageStatsManager = um;
mClock = new Clock();
mFilter = filter;
}
@Override
public List<PackageInfo> loadInBackground() {
return loadApps();
}
private List<PackageInfo> loadApps() {
ArraySet<Integer> seenUid = new ArraySet<>(); // some apps share a uid
long now = mClock.getCurrentTime();
long startTime = now - DateUtils.YEAR_IN_MILLIS;
final Map<String, UsageStats> map =
mUsageStatsManager.queryAndAggregateUsageStats(startTime, now);
final Map<String, UsageStats> alternateMap =
getLatestUsageStatsByPackageName(startTime, now);
List<ApplicationInfo> applicationInfos =
mPackageManager.getInstalledApplicationsAsUser(0, mUserId);
List<PackageInfo> stats = new ArrayList<>();
int size = applicationInfos.size();
mFilter.init();
for (int i = 0; i < size; i++) {
ApplicationInfo app = applicationInfos.get(i);
if (seenUid.contains(app.uid)) {
continue;
}
UsageStats usageStats = map.get(app.packageName);
UsageStats alternateUsageStats = alternateMap.get(app.packageName);
final AppStorageStats appSpace;
try {
appSpace = mStatsManager.getStatsForUid(app.volumeUuid, app.uid);
} catch (IOException e) {
Log.w(TAG, e);
continue;
}
PackageInfo extraInfo =
new PackageInfo.Builder()
.setDaysSinceLastUse(
getDaysSinceLastUse(
getGreaterUsageStats(
app.packageName,
usageStats,
alternateUsageStats)))
.setDaysSinceFirstInstall(getDaysSinceInstalled(app.packageName))
.setUserId(UserHandle.getUserId(app.uid))
.setPackageName(app.packageName)
.setSize(appSpace.getTotalBytes())
.setFlags(app.flags)
.setIcon(
mPackageManager.getUserBadgedIcon(
mPackageManager.loadUnbadgedItemIcon(app, app),
new UserHandle(UserHandle.getUserId(app.uid))))
.setLabel(app.loadLabel(mPackageManager))
.build();
seenUid.add(app.uid);
if (mFilter.filterApp(extraInfo) && !isDefaultLauncher(mPackageManager, extraInfo)) {
stats.add(extraInfo);
}
}
stats.sort(PACKAGE_INFO_COMPARATOR);
return stats;
}
@VisibleForTesting
UsageStats getGreaterUsageStats(String packageName, UsageStats primary, UsageStats alternate) {
long primaryLastUsed = primary != null ? primary.getLastTimeUsed() : 0;
long alternateLastUsed = alternate != null ? alternate.getLastTimeUsed() : 0;
if (primaryLastUsed != alternateLastUsed) {
Log.w(
TAG,
new StringBuilder("Usage stats mismatch for ")
.append(packageName)
.append(" ")
.append(primaryLastUsed)
.append(" ")
.append(alternateLastUsed)
.toString());
}
return (primaryLastUsed > alternateLastUsed) ? primary : alternate;
}
private Map<String, UsageStats> getLatestUsageStatsByPackageName(long startTime, long endTime) {
List<UsageStats> usageStats =
mUsageStatsManager.queryUsageStats(
UsageStatsManager.INTERVAL_YEARLY, startTime, endTime);
Map<String, List<UsageStats>> groupedByPackageName =
usageStats.stream().collect(Collectors.groupingBy(UsageStats::getPackageName));
ArrayMap<String, UsageStats> latestStatsByPackageName = new ArrayMap<>();
groupedByPackageName
.entrySet()
.stream()
.forEach(
// Flattens the list of UsageStats to only have the latest by
// getLastTimeUsed, retaining the package name as the key.
(Map.Entry<String, List<UsageStats>> item) -> {
latestStatsByPackageName.put(
item.getKey(),
Collections.max(
item.getValue(),
(UsageStats o1, UsageStats o2) ->
Long.compare(
o1.getLastTimeUsed(),
o2.getLastTimeUsed())));
});
return latestStatsByPackageName;
}
@Override
protected void onDiscardResult(List<PackageInfo> result) {}
private static boolean isDefaultLauncher(PackageManager packageManager, PackageInfo info) {
if (packageManager == null) {
return false;
}
final List<ResolveInfo> homeActivities = new ArrayList<>();
ComponentName defaultActivity = packageManager.getHomeActivities(homeActivities);
if (defaultActivity != null) {
String packageName = defaultActivity.getPackageName();
return packageName == null
? false
: defaultActivity.getPackageName().equals(info.packageName);
}
return false;
}
public static class Builder {
private Context mContext;
private int mUid;
private String mUuid;
private StorageStatsSource mStorageStatsSource;
private PackageManager mPackageManager;
private UsageStatsManager mUsageStatsManager;
private AppsAsyncLoader.AppFilter mFilter;
public Builder(Context context) {
mContext = context;
}
public Builder setUid(int uid) {
mUid = uid;
return this;
}
public Builder setUuid(String uuid) {
this.mUuid = uuid;
return this;
}
public Builder setStorageStatsSource(StorageStatsSource storageStatsSource) {
this.mStorageStatsSource = storageStatsSource;
return this;
}
public Builder setPackageManager(PackageManager packageManager) {
this.mPackageManager = packageManager;
return this;
}
public Builder setUsageStatsManager(UsageStatsManager usageStatsManager) {
this.mUsageStatsManager = usageStatsManager;
return this;
}
public Builder setFilter(AppFilter filter) {
this.mFilter = filter;
return this;
}
public AppsAsyncLoader build() {
return new AppsAsyncLoader(
mContext,
mUid,
mUuid,
mStorageStatsSource,
mPackageManager,
mUsageStatsManager,
mFilter);
}
}
/**
* Comparator that checks PackageInfo to see if it describes the same app based on the name and
* user it belongs to. This comparator does NOT fulfill the standard java equality contract
* because it only checks a few fields.
*/
public static final Comparator<PackageInfo> PACKAGE_INFO_COMPARATOR =
new Comparator<PackageInfo>() {
private final Collator sCollator = Collator.getInstance();
@Override
public int compare(PackageInfo object1, PackageInfo object2) {
if (object1.size < object2.size) return 1;
if (object1.size > object2.size) return -1;
int compareResult = sCollator.compare(object1.label, object2.label);
if (compareResult != 0) {
return compareResult;
}
compareResult = sCollator.compare(object1.packageName, object2.packageName);
if (compareResult != 0) {
return compareResult;
}
return object1.userId - object2.userId;
}
};
public static final AppFilter FILTER_NO_THRESHOLD =
new AppFilter() {
@Override
public void init() {}
@Override
public boolean filterApp(PackageInfo info) {
if (info == null) {
return false;
}
return !isBundled(info)
&& !isPersistentProcess(info)
&& isExtraInfoValid(info, MIN_DELETION_THRESHOLD);
}
};
/**
* Filters only non-system apps which haven't been used in the last 60 days. If an app's last
* usage is unknown, it is skipped.
*/
public static final AppFilter FILTER_USAGE_STATS =
new AppFilter() {
private long mUnusedDaysThreshold;
@Override
public void init() {
mUnusedDaysThreshold =
SystemProperties.getLong(
DEBUG_APP_UNUSED_OVERRIDE, UNUSED_DAYS_DELETION_THRESHOLD);
}
@Override
public boolean filterApp(PackageInfo info) {
if (info == null) {
return false;
}
return !isBundled(info)
&& !isPersistentProcess(info)
&& isExtraInfoValid(info, mUnusedDaysThreshold);
}
};
private static boolean isBundled(PackageInfo info) {
return (info.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
}
private static boolean isPersistentProcess(PackageInfo info) {
return (info.flags & ApplicationInfo.FLAG_PERSISTENT) != 0;
}
private static boolean isExtraInfoValid(Object extraInfo, long unusedDaysThreshold) {
if (extraInfo == null || !(extraInfo instanceof PackageInfo)) {
return false;
}
PackageInfo state = (PackageInfo) extraInfo;
// If we are missing information, let's be conservative and not show it.
if (state.daysSinceFirstInstall == UNKNOWN_LAST_USE
|| state.daysSinceLastUse == UNKNOWN_LAST_USE) {
Log.w(TAG, "Missing information. Skipping app");
return false;
}
// If the app has never been used, daysSinceLastUse is Long.MAX_VALUE, so the first
// install is always the most recent use.
long mostRecentUse = Math.min(state.daysSinceFirstInstall, state.daysSinceLastUse);
if (mostRecentUse >= unusedDaysThreshold) {
Log.i(TAG, "Accepting " + state.packageName + " with a minimum of " + mostRecentUse);
}
return mostRecentUse >= unusedDaysThreshold;
}
private long getDaysSinceLastUse(UsageStats stats) {
if (stats == null) {
return NEVER_USED;
}
long lastUsed = stats.getLastTimeUsed();
// Sometimes, a usage is recorded without a time and we don't know when the use was.
if (lastUsed <= 0) {
return UNKNOWN_LAST_USE;
}
// Theoretically, this should be impossible, but UsageStatsService, uh, finds a way.
long days = (TimeUnit.MILLISECONDS.toDays(mClock.getCurrentTime() - lastUsed));
if (days > DAYS_IN_A_TYPICAL_YEAR) {
return NEVER_USED;
}
return days;
}
private long getDaysSinceInstalled(String packageName) {
android.content.pm.PackageInfo pi = null;
try {
pi = mPackageManager.getPackageInfo(packageName, 0);
} catch (PackageManager.NameNotFoundException e) {
Log.e(TAG, packageName + " was not found.");
}
if (pi == null) {
return UNKNOWN_LAST_USE;
}
return (TimeUnit.MILLISECONDS.toDays(mClock.getCurrentTime() - pi.firstInstallTime));
}
public interface AppFilter {
/**
* Note: This method must be manually called before using an app filter. It does not get
* called on construction.
*/
void init();
default void init(Context context) {
init();
}
/**
* Returns true or false depending on whether the app should be filtered or not.
*
* @param info the PackageInfo for the app in question.
* @return true if the app should be included, false if it should be filtered out.
*/
boolean filterApp(PackageInfo info);
}
/** PackageInfo contains all the information needed to present apps for deletion to users. */
public static class PackageInfo {
public long daysSinceLastUse;
public long daysSinceFirstInstall;
public int userId;
public String packageName;
public long size;
public Drawable icon;
public CharSequence label;
/**
* Flags from {@link ApplicationInfo} that set whether the app is a regular app or something
* special like a system app.
*/
public int flags;
private PackageInfo(
long daysSinceLastUse,
long daysSinceFirstInstall,
int userId,
String packageName,
long size,
int flags,
Drawable icon,
CharSequence label) {
this.daysSinceLastUse = daysSinceLastUse;
this.daysSinceFirstInstall = daysSinceFirstInstall;
this.userId = userId;
this.packageName = packageName;
this.size = size;
this.flags = flags;
this.icon = icon;
this.label = label;
}
public static class Builder {
private long mDaysSinceLastUse;
private long mDaysSinceFirstInstall;
private int mUserId;
private String mPackageName;
private long mSize;
private int mFlags;
private Drawable mIcon;
private CharSequence mLabel;
public Builder setDaysSinceLastUse(long daysSinceLastUse) {
this.mDaysSinceLastUse = daysSinceLastUse;
return this;
}
public Builder setDaysSinceFirstInstall(long daysSinceFirstInstall) {
this.mDaysSinceFirstInstall = daysSinceFirstInstall;
return this;
}
public Builder setUserId(int userId) {
this.mUserId = userId;
return this;
}
public Builder setPackageName(String packageName) {
this.mPackageName = packageName;
return this;
}
public Builder setSize(long size) {
this.mSize = size;
return this;
}
public Builder setFlags(int flags) {
this.mFlags = flags;
return this;
}
public Builder setIcon(Drawable icon) {
this.mIcon = icon;
return this;
}
public Builder setLabel(CharSequence label) {
this.mLabel = label;
return this;
}
public PackageInfo build() {
return new PackageInfo(
mDaysSinceLastUse,
mDaysSinceFirstInstall,
mUserId,
mPackageName,
mSize,
mFlags,
mIcon,
mLabel);
}
}
}
/** Clock provides the current time. */
static class Clock {
public long getCurrentTime() {
return System.currentTimeMillis();
}
}
}