1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.storagemanager.deletionhelper;
18 
19 import android.app.usage.UsageStats;
20 import android.app.usage.UsageStatsManager;
21 import android.content.ComponentName;
22 import android.content.Context;
23 import android.content.pm.ApplicationInfo;
24 import android.content.pm.PackageManager;
25 import android.content.pm.ResolveInfo;
26 import android.graphics.drawable.Drawable;
27 import android.os.SystemProperties;
28 import android.os.UserHandle;
29 import androidx.annotation.VisibleForTesting;
30 import android.text.format.DateUtils;
31 import android.util.ArrayMap;
32 import android.util.ArraySet;
33 import android.util.Log;
34 import com.android.settingslib.applications.StorageStatsSource;
35 import com.android.settingslib.applications.StorageStatsSource.AppStorageStats;
36 import com.android.storagemanager.deletionhelper.AppsAsyncLoader.PackageInfo;
37 import com.android.storagemanager.utils.AsyncLoader;
38 
39 import java.io.IOException;
40 import java.text.Collator;
41 import java.util.ArrayList;
42 import java.util.Comparator;
43 import java.util.List;
44 import java.util.Map;
45 import java.util.concurrent.TimeUnit;
46 import java.util.Collections;
47 import java.util.stream.Collectors;
48 
49 /**
50  * AppsAsyncLoader is a Loader which loads app storage information and categories it by the app's
51  * specified categorization.
52  */
53 public class AppsAsyncLoader extends AsyncLoader<List<PackageInfo>> {
54     private static final String TAG = "AppsAsyncLoader";
55 
56     public static final long NEVER_USED = Long.MAX_VALUE;
57     public static final long UNKNOWN_LAST_USE = -1;
58     public static final long UNUSED_DAYS_DELETION_THRESHOLD = 90;
59     public static final long MIN_DELETION_THRESHOLD = Long.MIN_VALUE;
60     public static final int NORMAL_THRESHOLD = 0;
61     public static final int SIZE_UNKNOWN = -1;
62     public static final int SIZE_INVALID = -2;
63     public static final int NO_THRESHOLD = 1;
64     private static final String DEBUG_APP_UNUSED_OVERRIDE = "debug.asm.app_unused_limit";
65     private static final long DAYS_IN_A_TYPICAL_YEAR = 365;
66 
67     protected Clock mClock;
68     protected AppsAsyncLoader.AppFilter mFilter;
69     private int mUserId;
70     private String mUuid;
71     private StorageStatsSource mStatsManager;
72     private PackageManager mPackageManager;
73 
74     private UsageStatsManager mUsageStatsManager;
75 
AppsAsyncLoader( Context context, int userId, String uuid, StorageStatsSource source, PackageManager pm, UsageStatsManager um, AppsAsyncLoader.AppFilter filter)76     private AppsAsyncLoader(
77             Context context,
78             int userId,
79             String uuid,
80             StorageStatsSource source,
81             PackageManager pm,
82             UsageStatsManager um,
83             AppsAsyncLoader.AppFilter filter) {
84         super(context);
85         mUserId = userId;
86         mUuid = uuid;
87         mStatsManager = source;
88         mPackageManager = pm;
89         mUsageStatsManager = um;
90         mClock = new Clock();
91         mFilter = filter;
92     }
93 
94     @Override
loadInBackground()95     public List<PackageInfo> loadInBackground() {
96         return loadApps();
97     }
98 
loadApps()99     private List<PackageInfo> loadApps() {
100         ArraySet<Integer> seenUid = new ArraySet<>(); // some apps share a uid
101 
102         long now = mClock.getCurrentTime();
103         long startTime = now - DateUtils.YEAR_IN_MILLIS;
104         final Map<String, UsageStats> map =
105                 mUsageStatsManager.queryAndAggregateUsageStats(startTime, now);
106         final Map<String, UsageStats> alternateMap =
107                 getLatestUsageStatsByPackageName(startTime, now);
108 
109         List<ApplicationInfo> applicationInfos =
110                 mPackageManager.getInstalledApplicationsAsUser(0, mUserId);
111         List<PackageInfo> stats = new ArrayList<>();
112         int size = applicationInfos.size();
113         mFilter.init();
114         for (int i = 0; i < size; i++) {
115             ApplicationInfo app = applicationInfos.get(i);
116             if (seenUid.contains(app.uid)) {
117                 continue;
118             }
119 
120             UsageStats usageStats = map.get(app.packageName);
121             UsageStats alternateUsageStats = alternateMap.get(app.packageName);
122 
123             final AppStorageStats appSpace;
124             try {
125                 appSpace = mStatsManager.getStatsForUid(app.volumeUuid, app.uid);
126             } catch (IOException e) {
127                 Log.w(TAG, e);
128                 continue;
129             }
130 
131             PackageInfo extraInfo =
132                     new PackageInfo.Builder()
133                             .setDaysSinceLastUse(
134                                     getDaysSinceLastUse(
135                                             getGreaterUsageStats(
136                                                     app.packageName,
137                                                     usageStats,
138                                                     alternateUsageStats)))
139                             .setDaysSinceFirstInstall(getDaysSinceInstalled(app.packageName))
140                             .setUserId(UserHandle.getUserId(app.uid))
141                             .setPackageName(app.packageName)
142                             .setSize(appSpace.getTotalBytes())
143                             .setFlags(app.flags)
144                             .setIcon(
145                                     mPackageManager.getUserBadgedIcon(
146                                             mPackageManager.loadUnbadgedItemIcon(app, app),
147                                             new UserHandle(UserHandle.getUserId(app.uid))))
148                             .setLabel(app.loadLabel(mPackageManager))
149                             .build();
150             seenUid.add(app.uid);
151             if (mFilter.filterApp(extraInfo) && !isDefaultLauncher(mPackageManager, extraInfo)) {
152                 stats.add(extraInfo);
153             }
154         }
155         stats.sort(PACKAGE_INFO_COMPARATOR);
156         return stats;
157     }
158 
159     @VisibleForTesting
getGreaterUsageStats(String packageName, UsageStats primary, UsageStats alternate)160     UsageStats getGreaterUsageStats(String packageName, UsageStats primary, UsageStats alternate) {
161         long primaryLastUsed = primary != null ? primary.getLastTimeUsed() : 0;
162         long alternateLastUsed = alternate != null ? alternate.getLastTimeUsed() : 0;
163 
164         if (primaryLastUsed != alternateLastUsed) {
165             Log.w(
166                     TAG,
167                     new StringBuilder("Usage stats mismatch for ")
168                             .append(packageName)
169                             .append(" ")
170                             .append(primaryLastUsed)
171                             .append(" ")
172                             .append(alternateLastUsed)
173                             .toString());
174         }
175 
176         return (primaryLastUsed > alternateLastUsed) ? primary : alternate;
177     }
178 
getLatestUsageStatsByPackageName(long startTime, long endTime)179     private Map<String, UsageStats> getLatestUsageStatsByPackageName(long startTime, long endTime) {
180         List<UsageStats> usageStats =
181                 mUsageStatsManager.queryUsageStats(
182                         UsageStatsManager.INTERVAL_YEARLY, startTime, endTime);
183         Map<String, List<UsageStats>> groupedByPackageName =
184                 usageStats.stream().collect(Collectors.groupingBy(UsageStats::getPackageName));
185 
186         ArrayMap<String, UsageStats> latestStatsByPackageName = new ArrayMap<>();
187         groupedByPackageName
188                 .entrySet()
189                 .stream()
190                 .forEach(
191                         // Flattens the list of UsageStats to only have the latest by
192                         // getLastTimeUsed, retaining the package name as the key.
193                         (Map.Entry<String, List<UsageStats>> item) -> {
194                             latestStatsByPackageName.put(
195                                     item.getKey(),
196                                     Collections.max(
197                                             item.getValue(),
198                                             (UsageStats o1, UsageStats o2) ->
199                                                     Long.compare(
200                                                             o1.getLastTimeUsed(),
201                                                             o2.getLastTimeUsed())));
202                         });
203 
204         return latestStatsByPackageName;
205     }
206 
207     @Override
onDiscardResult(List<PackageInfo> result)208     protected void onDiscardResult(List<PackageInfo> result) {}
209 
isDefaultLauncher(PackageManager packageManager, PackageInfo info)210     private static boolean isDefaultLauncher(PackageManager packageManager, PackageInfo info) {
211         if (packageManager == null) {
212             return false;
213         }
214 
215         final List<ResolveInfo> homeActivities = new ArrayList<>();
216         ComponentName defaultActivity = packageManager.getHomeActivities(homeActivities);
217         if (defaultActivity != null) {
218             String packageName = defaultActivity.getPackageName();
219             return packageName == null
220                     ? false
221                     : defaultActivity.getPackageName().equals(info.packageName);
222         }
223 
224         return false;
225     }
226 
227     public static class Builder {
228         private Context mContext;
229         private int mUid;
230         private String mUuid;
231         private StorageStatsSource mStorageStatsSource;
232         private PackageManager mPackageManager;
233         private UsageStatsManager mUsageStatsManager;
234         private AppsAsyncLoader.AppFilter mFilter;
235 
Builder(Context context)236         public Builder(Context context) {
237             mContext = context;
238         }
239 
setUid(int uid)240         public Builder setUid(int uid) {
241             mUid = uid;
242             return this;
243         }
244 
setUuid(String uuid)245         public Builder setUuid(String uuid) {
246             this.mUuid = uuid;
247             return this;
248         }
249 
setStorageStatsSource(StorageStatsSource storageStatsSource)250         public Builder setStorageStatsSource(StorageStatsSource storageStatsSource) {
251             this.mStorageStatsSource = storageStatsSource;
252             return this;
253         }
254 
setPackageManager(PackageManager packageManager)255         public Builder setPackageManager(PackageManager packageManager) {
256             this.mPackageManager = packageManager;
257             return this;
258         }
259 
setUsageStatsManager(UsageStatsManager usageStatsManager)260         public Builder setUsageStatsManager(UsageStatsManager usageStatsManager) {
261             this.mUsageStatsManager = usageStatsManager;
262             return this;
263         }
264 
setFilter(AppFilter filter)265         public Builder setFilter(AppFilter filter) {
266             this.mFilter = filter;
267             return this;
268         }
269 
build()270         public AppsAsyncLoader build() {
271             return new AppsAsyncLoader(
272                     mContext,
273                     mUid,
274                     mUuid,
275                     mStorageStatsSource,
276                     mPackageManager,
277                     mUsageStatsManager,
278                     mFilter);
279         }
280     }
281 
282     /**
283      * Comparator that checks PackageInfo to see if it describes the same app based on the name and
284      * user it belongs to. This comparator does NOT fulfill the standard java equality contract
285      * because it only checks a few fields.
286      */
287     public static final Comparator<PackageInfo> PACKAGE_INFO_COMPARATOR =
288             new Comparator<PackageInfo>() {
289                 private final Collator sCollator = Collator.getInstance();
290 
291                 @Override
292                 public int compare(PackageInfo object1, PackageInfo object2) {
293                     if (object1.size < object2.size) return 1;
294                     if (object1.size > object2.size) return -1;
295                     int compareResult = sCollator.compare(object1.label, object2.label);
296                     if (compareResult != 0) {
297                         return compareResult;
298                     }
299                     compareResult = sCollator.compare(object1.packageName, object2.packageName);
300                     if (compareResult != 0) {
301                         return compareResult;
302                     }
303                     return object1.userId - object2.userId;
304                 }
305             };
306 
307     public static final AppFilter FILTER_NO_THRESHOLD =
308             new AppFilter() {
309                 @Override
310                 public void init() {}
311 
312                 @Override
313                 public boolean filterApp(PackageInfo info) {
314                     if (info == null) {
315                         return false;
316                     }
317                     return !isBundled(info)
318                             && !isPersistentProcess(info)
319                             && isExtraInfoValid(info, MIN_DELETION_THRESHOLD);
320                 }
321             };
322 
323     /**
324      * Filters only non-system apps which haven't been used in the last 60 days. If an app's last
325      * usage is unknown, it is skipped.
326      */
327     public static final AppFilter FILTER_USAGE_STATS =
328             new AppFilter() {
329                 private long mUnusedDaysThreshold;
330 
331                 @Override
332                 public void init() {
333                     mUnusedDaysThreshold =
334                             SystemProperties.getLong(
335                                     DEBUG_APP_UNUSED_OVERRIDE, UNUSED_DAYS_DELETION_THRESHOLD);
336                 }
337 
338                 @Override
339                 public boolean filterApp(PackageInfo info) {
340                     if (info == null) {
341                         return false;
342                     }
343                     return !isBundled(info)
344                             && !isPersistentProcess(info)
345                             && isExtraInfoValid(info, mUnusedDaysThreshold);
346                 }
347             };
348 
isBundled(PackageInfo info)349     private static boolean isBundled(PackageInfo info) {
350         return (info.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
351     }
352 
isPersistentProcess(PackageInfo info)353     private static boolean isPersistentProcess(PackageInfo info) {
354         return (info.flags & ApplicationInfo.FLAG_PERSISTENT) != 0;
355     }
356 
isExtraInfoValid(Object extraInfo, long unusedDaysThreshold)357     private static boolean isExtraInfoValid(Object extraInfo, long unusedDaysThreshold) {
358         if (extraInfo == null || !(extraInfo instanceof PackageInfo)) {
359             return false;
360         }
361 
362         PackageInfo state = (PackageInfo) extraInfo;
363 
364         // If we are missing information, let's be conservative and not show it.
365         if (state.daysSinceFirstInstall == UNKNOWN_LAST_USE
366                 || state.daysSinceLastUse == UNKNOWN_LAST_USE) {
367             Log.w(TAG, "Missing information. Skipping app");
368             return false;
369         }
370 
371         // If the app has never been used, daysSinceLastUse is Long.MAX_VALUE, so the first
372         // install is always the most recent use.
373         long mostRecentUse = Math.min(state.daysSinceFirstInstall, state.daysSinceLastUse);
374         if (mostRecentUse >= unusedDaysThreshold) {
375             Log.i(TAG, "Accepting " + state.packageName + " with a minimum of " + mostRecentUse);
376         }
377         return mostRecentUse >= unusedDaysThreshold;
378     }
379 
getDaysSinceLastUse(UsageStats stats)380     private long getDaysSinceLastUse(UsageStats stats) {
381         if (stats == null) {
382             return NEVER_USED;
383         }
384         long lastUsed = stats.getLastTimeUsed();
385         // Sometimes, a usage is recorded without a time and we don't know when the use was.
386         if (lastUsed <= 0) {
387             return UNKNOWN_LAST_USE;
388         }
389 
390         // Theoretically, this should be impossible, but UsageStatsService, uh, finds a way.
391         long days = (TimeUnit.MILLISECONDS.toDays(mClock.getCurrentTime() - lastUsed));
392         if (days > DAYS_IN_A_TYPICAL_YEAR) {
393             return NEVER_USED;
394         }
395         return days;
396     }
397 
getDaysSinceInstalled(String packageName)398     private long getDaysSinceInstalled(String packageName) {
399         android.content.pm.PackageInfo pi = null;
400         try {
401             pi = mPackageManager.getPackageInfo(packageName, 0);
402         } catch (PackageManager.NameNotFoundException e) {
403             Log.e(TAG, packageName + " was not found.");
404         }
405 
406         if (pi == null) {
407             return UNKNOWN_LAST_USE;
408         }
409         return (TimeUnit.MILLISECONDS.toDays(mClock.getCurrentTime() - pi.firstInstallTime));
410     }
411 
412     public interface AppFilter {
413 
414         /**
415          * Note: This method must be manually called before using an app filter. It does not get
416          * called on construction.
417          */
init()418         void init();
419 
init(Context context)420         default void init(Context context) {
421             init();
422         }
423 
424         /**
425          * Returns true or false depending on whether the app should be filtered or not.
426          *
427          * @param info the PackageInfo for the app in question.
428          * @return true if the app should be included, false if it should be filtered out.
429          */
filterApp(PackageInfo info)430         boolean filterApp(PackageInfo info);
431     }
432 
433     /** PackageInfo contains all the information needed to present apps for deletion to users. */
434     public static class PackageInfo {
435 
436         public long daysSinceLastUse;
437         public long daysSinceFirstInstall;
438         public int userId;
439         public String packageName;
440         public long size;
441         public Drawable icon;
442         public CharSequence label;
443         /**
444          * Flags from {@link ApplicationInfo} that set whether the app is a regular app or something
445          * special like a system app.
446          */
447         public int flags;
448 
PackageInfo( long daysSinceLastUse, long daysSinceFirstInstall, int userId, String packageName, long size, int flags, Drawable icon, CharSequence label)449         private PackageInfo(
450                 long daysSinceLastUse,
451                 long daysSinceFirstInstall,
452                 int userId,
453                 String packageName,
454                 long size,
455                 int flags,
456                 Drawable icon,
457                 CharSequence label) {
458             this.daysSinceLastUse = daysSinceLastUse;
459             this.daysSinceFirstInstall = daysSinceFirstInstall;
460             this.userId = userId;
461             this.packageName = packageName;
462             this.size = size;
463             this.flags = flags;
464             this.icon = icon;
465             this.label = label;
466         }
467 
468         public static class Builder {
469             private long mDaysSinceLastUse;
470             private long mDaysSinceFirstInstall;
471             private int mUserId;
472             private String mPackageName;
473             private long mSize;
474             private int mFlags;
475             private Drawable mIcon;
476             private CharSequence mLabel;
477 
setDaysSinceLastUse(long daysSinceLastUse)478             public Builder setDaysSinceLastUse(long daysSinceLastUse) {
479                 this.mDaysSinceLastUse = daysSinceLastUse;
480                 return this;
481             }
482 
setDaysSinceFirstInstall(long daysSinceFirstInstall)483             public Builder setDaysSinceFirstInstall(long daysSinceFirstInstall) {
484                 this.mDaysSinceFirstInstall = daysSinceFirstInstall;
485                 return this;
486             }
487 
setUserId(int userId)488             public Builder setUserId(int userId) {
489                 this.mUserId = userId;
490                 return this;
491             }
492 
setPackageName(String packageName)493             public Builder setPackageName(String packageName) {
494                 this.mPackageName = packageName;
495                 return this;
496             }
497 
setSize(long size)498             public Builder setSize(long size) {
499                 this.mSize = size;
500                 return this;
501             }
502 
setFlags(int flags)503             public Builder setFlags(int flags) {
504                 this.mFlags = flags;
505                 return this;
506             }
507 
setIcon(Drawable icon)508             public Builder setIcon(Drawable icon) {
509                 this.mIcon = icon;
510                 return this;
511             }
512 
setLabel(CharSequence label)513             public Builder setLabel(CharSequence label) {
514                 this.mLabel = label;
515                 return this;
516             }
517 
build()518             public PackageInfo build() {
519                 return new PackageInfo(
520                         mDaysSinceLastUse,
521                         mDaysSinceFirstInstall,
522                         mUserId,
523                         mPackageName,
524                         mSize,
525                         mFlags,
526                         mIcon,
527                         mLabel);
528             }
529         }
530     }
531 
532     /** Clock provides the current time. */
533     static class Clock {
getCurrentTime()534         public long getCurrentTime() {
535             return System.currentTimeMillis();
536         }
537     }
538 }
539