From e6e325a936f8fa85d5e77cb55b9d06d26a38483e Mon Sep 17 00:00:00 2001 From: Muntashir Al-Islam Date: Wed, 6 Nov 2024 10:15:14 -0800 Subject: [PATCH] [Usage] Attempt to fix the usage time calculation method The usage events returned by the UsageStatsManager was assumed to be in order of their timestamp which does not seem to be true and resulted in missing a few events due to the standard calculation method, that is, calculating the time difference between activity resume and pause time. In addition, it appears that the system may log activity stop time without logging any pause time (a typical cycle would be resume --> pause --> stop) causing further miss of events. All of these issues are addressed by sorting the events in order of their timestamp as well as measure the time difference between resume and stop times instead of resume and pause times. However, this does not solve the problems with access count since it is still is a summation of the resume-stop cycles. Signed-off-by: Muntashir Al-Islam --- .../compat/UsageStatsManagerCompat.java | 62 ++++++-- .../usage/AppUsageStatsManager.java | 144 +++++++++--------- 2 files changed, 123 insertions(+), 83 deletions(-) diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/compat/UsageStatsManagerCompat.java b/app/src/main/java/io/github/muntashirakon/AppManager/compat/UsageStatsManagerCompat.java index 9abb6f63bcf..68533e768b8 100644 --- a/app/src/main/java/io/github/muntashirakon/AppManager/compat/UsageStatsManagerCompat.java +++ b/app/src/main/java/io/github/muntashirakon/AppManager/compat/UsageStatsManagerCompat.java @@ -10,9 +10,18 @@ import android.os.RemoteException; import android.os.UserHandleHidden; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresPermission; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + import io.github.muntashirakon.AppManager.ipc.ProxyBinder; import io.github.muntashirakon.AppManager.self.SelfPermissions; import io.github.muntashirakon.AppManager.users.Users; +import io.github.muntashirakon.AppManager.utils.ArrayUtils; import io.github.muntashirakon.AppManager.utils.BroadcastUtils; import io.github.muntashirakon.AppManager.utils.ContextUtils; import io.github.muntashirakon.AppManager.utils.ExUtils; @@ -30,21 +39,52 @@ public final class UsageStatsManagerCompat { } } - public static UsageEvents queryEvents(long beginTime, long endTime, int userId) throws RemoteException { - IUsageStatsManager usm = getUsageStatsManager(); - String callingPackage = SelfPermissions.getCallingPackage(Users.getSelfOrRemoteUid()); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - return usm.queryEventsForUser(beginTime, endTime, userId, callingPackage); + @RequiresPermission("android.permission.PACKAGE_USAGE_STATS") + @Nullable + public static UsageEvents queryEvents(long beginTime, long endTime, int userId) { + try { + IUsageStatsManager usm = getUsageStatsManager(); + String callingPackage = SelfPermissions.getCallingPackage(Users.getSelfOrRemoteUid()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return usm.queryEventsForUser(beginTime, endTime, userId, callingPackage); + } + return usm.queryEvents(beginTime, endTime, callingPackage); + } catch (RemoteException e) { + return ExUtils.rethrowFromSystemServer(e); + } + } + + /** + * Note: This method should only be used when sorted entries are required as the operations done + * here are expensive. + */ + @RequiresPermission("android.permission.PACKAGE_USAGE_STATS") + @NonNull + public static List queryEventsSorted(long beginTime, long endTime, int userId, int[] filterEvents) { + List filteredEvents = new ArrayList<>(); + UsageEvents events = queryEvents(beginTime, endTime, userId); + if (events != null) { + while (events.hasNextEvent()) { + UsageEvents.Event event = new UsageEvents.Event(); + events.getNextEvent(event); + if (ArrayUtils.contains(filterEvents, event.getEventType())) { + filteredEvents.add(event); + } + } + Collections.sort(filteredEvents, (o1, o2) -> -Long.compare(o1.getTimeStamp(), o2.getTimeStamp())); } - return usm.queryEvents(beginTime, endTime, callingPackage); + return filteredEvents; } - public static void setAppInactive(String packageName, @UserIdInt int userId, boolean inactive) - throws RemoteException { + public static void setAppInactive(String packageName, @UserIdInt int userId, boolean inactive) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - getUsageStatsManager().setAppInactive(packageName, inactive, userId); - if (userId != UserHandleHidden.myUserId()) { - BroadcastUtils.sendPackageAltered(ContextUtils.getContext(), new String[]{packageName}); + try { + getUsageStatsManager().setAppInactive(packageName, inactive, userId); + if (userId != UserHandleHidden.myUserId()) { + BroadcastUtils.sendPackageAltered(ContextUtils.getContext(), new String[]{packageName}); + } + } catch (RemoteException e) { + ExUtils.rethrowFromSystemServer(e); } } } diff --git a/app/src/main/java/io/github/muntashirakon/AppManager/usage/AppUsageStatsManager.java b/app/src/main/java/io/github/muntashirakon/AppManager/usage/AppUsageStatsManager.java index e1ed57b3871..99a4906103b 100644 --- a/app/src/main/java/io/github/muntashirakon/AppManager/usage/AppUsageStatsManager.java +++ b/app/src/main/java/io/github/muntashirakon/AppManager/usage/AppUsageStatsManager.java @@ -37,6 +37,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import io.github.muntashirakon.AppManager.compat.ManifestCompat; import io.github.muntashirakon.AppManager.compat.NetworkStatsCompat; @@ -163,17 +164,17 @@ public List getUsageStats(@UsageUtils.IntervalType int usageIn throws RemoteException, SecurityException { List packageUsageInfoList = new ArrayList<>(); int _try = 5; // try to get usage stats at most 5 times - RemoteException re; + Throwable re; do { try { packageUsageInfoList.addAll(getUsageStatsInternal(usageInterval, userId)); re = null; - } catch (RemoteException e) { + } catch (Throwable e) { re = e; } } while (0 != --_try && packageUsageInfoList.isEmpty()); if (re != null) { - throw re; + throw (RemoteException) (new RemoteException(re.getMessage()).initCause(re)); } return packageUsageInfoList; } @@ -188,27 +189,31 @@ public PackageUsageInfo getUsageStatsForPackage(@NonNull String packageName, ApplicationInfo applicationInfo = PackageManagerCompat.getApplicationInfo(packageName, MATCH_UNINSTALLED_PACKAGES | PackageManagerCompat.MATCH_STATIC_SHARED_AND_SDK_LIBRARIES, userId); PackageUsageInfo packageUsageInfo = new PackageUsageInfo(mContext, packageName, userId, applicationInfo); - UsageEvents events = UsageStatsManagerCompat.queryEvents(range.getStartTime(), range.getEndTime(), userId); - if (events == null) return packageUsageInfo; - UsageEvents.Event event = new UsageEvents.Event(); + List events = UsageStatsManagerCompat.queryEventsSorted(range.getStartTime(), range.getEndTime(), userId, new int[]{UsageEvents.Event.ACTIVITY_RESUMED, UsageEvents.Event.ACTIVITY_PAUSED, UsageEvents.Event.ACTIVITY_STOPPED}); List usEntries = new ArrayList<>(); - long startTime = 0; long endTime = 0; - while (events.hasNextEvent()) { - events.getNextEvent(event); - String currentPackageName = event.getPackageName(); - int eventType = event.getEventType(); - long eventTime = event.getTimeStamp(); - if (currentPackageName.equals(packageName)) { - if (eventType == UsageEvents.Event.ACTIVITY_RESUMED) { - if (startTime == 0) startTime = eventTime; - } else if (eventType == UsageEvents.Event.ACTIVITY_PAUSED) { - if (startTime > 0) endTime = eventTime; + for (UsageEvents.Event event : events) { + if (Objects.equals(packageName, event.getPackageName())) { + int eventType = event.getEventType(); + // Queries are sorted in descending order, so a not-running activity should be paused + // or stopped first and then resumed (i.e., reversed logic) + if (eventType == UsageEvents.Event.ACTIVITY_STOPPED || eventType == UsageEvents.Event.ACTIVITY_PAUSED) { + if (endTime > 0) { + // Log.d(TAG, "Start time non-zero (%d) for package %s", endTime, packageName); + // Prefer stop times over pause. So, ignore all the subsequent events until an + // resume event is found. This may result in inaccurate access count. However, + // this inaccuracy is acceptable. + continue; + } + endTime = event.getTimeStamp(); + } else if (eventType == UsageEvents.Event.ACTIVITY_RESUMED) { + if (endTime == 0) { + Log.d(TAG, "Start time is zero for package %s, skipping...", packageName); + continue; + } + usEntries.add(new PackageUsageInfo.Entry(event.getTimeStamp(), endTime)); + endTime = 0; } - } else if (startTime > 0 && endTime > 0) { - usEntries.add(new PackageUsageInfo.Entry(startTime, endTime)); - startTime = 0; - endTime = 0; } } packageUsageInfo.entries = usEntries; @@ -226,53 +231,50 @@ public PackageUsageInfo getUsageStatsForPackage(@NonNull String packageName, */ @NonNull private List getUsageStatsInternal(@UsageUtils.IntervalType int usageInterval, - @UserIdInt int userId) - throws RemoteException { + @UserIdInt int userId) { List screenTimeList = new ArrayList<>(); + Map endTimes = new HashMap<>(); Map screenTimes = new HashMap<>(); Map lastUse = new HashMap<>(); Map accessCount = new HashMap<>(); // Get events UsageUtils.TimeInterval interval = UsageUtils.getTimeInterval(usageInterval); - UsageEvents events = UsageStatsManagerCompat.queryEvents(interval.getStartTime(), interval.getEndTime(), userId); - if (events == null) { - return Collections.emptyList(); - } - UsageEvents.Event event = new UsageEvents.Event(); - long startTime; - long endTime; - boolean skip_new = false; - while (events.hasNextEvent()) { - if (!skip_new) events.getNextEvent(event); + List events = UsageStatsManagerCompat.queryEventsSorted(interval.getStartTime(), interval.getEndTime(), userId, new int[]{UsageEvents.Event.ACTIVITY_RESUMED, UsageEvents.Event.ACTIVITY_PAUSED, UsageEvents.Event.ACTIVITY_STOPPED}); + for (UsageEvents.Event event : events) { int eventType = event.getEventType(); - long eventTime = event.getTimeStamp(); String packageName = event.getPackageName(); - if (eventType == UsageEvents.Event.ACTIVITY_RESUMED) { // App opened: MOVE_TO_FOREGROUND - startTime = eventTime; - while (events.hasNextEvent()) { - events.getNextEvent(event); - eventType = event.getEventType(); - eventTime = event.getTimeStamp(); - if (eventType == UsageEvents.Event.ACTIVITY_RESUMED) { - skip_new = true; - break; - } else if (eventType == UsageEvents.Event.ACTIVITY_PAUSED) { - endTime = eventTime; - skip_new = false; - if (packageName.equals(event.getPackageName())) { - long time = endTime - startTime + 1; - if (screenTimes.containsKey(packageName)) { - screenTimes.put(packageName, NonNullUtils.defeatNullable(screenTimes - .get(packageName)) + time); - } else screenTimes.put(packageName, time); - lastUse.put(packageName, endTime); - if (accessCount.containsKey(packageName)) { - accessCount.put(packageName, NonNullUtils.defeatNullable(accessCount - .get(packageName)) + 1); - } else accessCount.put(packageName, 1); - } - break; - } + // Queries are sorted in descending order, so a not-running activity should be paused or + // stopped first and then resumed (i.e., reversed logic). + if (eventType == UsageEvents.Event.ACTIVITY_STOPPED || eventType == UsageEvents.Event.ACTIVITY_PAUSED) { + if (endTimes.get(packageName) != null) { + // Log.d(TAG, "(%2d) Start time non-zero (%d) for package %s", eventType, endTimes.get(packageName), packageName); + // Prefer stop times over pause. So, ignore all the subsequent events until an + // resume event is found. This may result in inaccurate access count. However, + // this inaccuracy is acceptable. + continue; + } + // Override previous pause/stops + endTimes.put(event.getPackageName(), event.getTimeStamp()); + } else if (eventType == UsageEvents.Event.ACTIVITY_RESUMED) { + Long endTime = endTimes.remove(packageName); + if (endTime == null) { + Log.d(TAG, "Start time is zero for package %s", packageName); + continue; + } + long time = endTime - event.getTimeStamp(); + if (screenTimes.containsKey(packageName)) { + screenTimes.put(packageName, NonNullUtils.defeatNullable(screenTimes + .get(packageName)) + time); + } else screenTimes.put(packageName, time); + // FIXME: 11/6/24 Access count is still not perfect since it counts access through + // activities. Instead, access counts should be measured for the entire time the + // app is running after it's opened. + if (accessCount.containsKey(packageName)) { + accessCount.put(packageName, NonNullUtils.defeatNullable(accessCount + .get(packageName)) + 1); + } else accessCount.put(packageName, 1); + if (lastUse.get(packageName) == null) { + lastUse.put(packageName, event.getTimeStamp()); } } } @@ -303,20 +305,18 @@ private List getUsageStatsInternal(@UsageUtils.IntervalType in @RequiresPermission("android.permission.PACKAGE_USAGE_STATS") public static long getLastActivityTime(String packageName, @NonNull UsageUtils.TimeInterval interval) { - try { - UsageEvents events = UsageStatsManagerCompat.queryEvents(interval.getStartTime(), interval.getEndTime(), - UserHandleHidden.myUserId()); - if (events == null) return 0L; - UsageEvents.Event event = new UsageEvents.Event(); - while (events.hasNextEvent()) { - events.getNextEvent(event); - if (event.getPackageName().equals(packageName)) { - return event.getTimeStamp(); - } + UsageEvents events = UsageStatsManagerCompat.queryEvents(interval.getStartTime(), interval.getEndTime(), + UserHandleHidden.myUserId()); + if (events == null) return 0L; + UsageEvents.Event event = new UsageEvents.Event(); + long lastTime = 0L; + while (events.hasNextEvent()) { + events.getNextEvent(event); + if (event.getPackageName().equals(packageName) && lastTime < event.getTimeStamp()) { + lastTime = event.getTimeStamp(); } - } catch (RemoteException ignore) { } - return 0L; + return lastTime; } @NonNull