diff options
9 files changed, 946 insertions, 1 deletions
diff --git a/api/system-current.txt b/api/system-current.txt index 055526309093..b5911e6480e4 100644 --- a/api/system-current.txt +++ b/api/system-current.txt @@ -723,9 +723,14 @@ package android.app.usage { public final class UsageStatsManager { method public int getAppStandbyBucket(java.lang.String); method public java.util.Map<java.lang.String, java.lang.Integer> getAppStandbyBuckets(); + method public void registerAppUsageObserver(int, java.lang.String[], long, java.util.concurrent.TimeUnit, android.app.PendingIntent); method public void setAppStandbyBucket(java.lang.String, int); method public void setAppStandbyBuckets(java.util.Map<java.lang.String, java.lang.Integer>); + method public void unregisterAppUsageObserver(int); method public void whitelistAppTemporarily(java.lang.String, long, android.os.UserHandle); + field public static final java.lang.String EXTRA_OBSERVER_ID = "android.app.usage.extra.OBSERVER_ID"; + field public static final java.lang.String EXTRA_TIME_LIMIT = "android.app.usage.extra.TIME_LIMIT"; + field public static final java.lang.String EXTRA_TIME_USED = "android.app.usage.extra.TIME_USED"; field public static final int STANDBY_BUCKET_EXEMPTED = 5; // 0x5 field public static final int STANDBY_BUCKET_NEVER = 50; // 0x32 } diff --git a/core/java/android/app/usage/IUsageStatsManager.aidl b/core/java/android/app/usage/IUsageStatsManager.aidl index fff1a00c585e..d52bd3764101 100644 --- a/core/java/android/app/usage/IUsageStatsManager.aidl +++ b/core/java/android/app/usage/IUsageStatsManager.aidl @@ -16,6 +16,7 @@ package android.app.usage; +import android.app.PendingIntent; import android.app.usage.UsageEvents; import android.content.pm.ParceledListSlice; @@ -43,4 +44,7 @@ interface IUsageStatsManager { void setAppStandbyBucket(String packageName, int bucket, int userId); ParceledListSlice getAppStandbyBuckets(String callingPackage, int userId); void setAppStandbyBuckets(in ParceledListSlice appBuckets, int userId); + void registerAppUsageObserver(int observerId, in String[] packages, long timeLimitMs, + in PendingIntent callback, String callingPackage); + void unregisterAppUsageObserver(int observerId, String callingPackage); } diff --git a/core/java/android/app/usage/UsageStatsManager.java b/core/java/android/app/usage/UsageStatsManager.java index 5f9fa43203da..59f001c5a7c3 100644 --- a/core/java/android/app/usage/UsageStatsManager.java +++ b/core/java/android/app/usage/UsageStatsManager.java @@ -20,6 +20,7 @@ import android.annotation.IntDef; import android.annotation.RequiresPermission; import android.annotation.SystemApi; import android.annotation.SystemService; +import android.app.PendingIntent; import android.content.Context; import android.content.pm.ParceledListSlice; import android.os.RemoteException; @@ -32,6 +33,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; /** * Provides access to device usage history and statistics. Usage data is aggregated into @@ -179,6 +181,31 @@ public final class UsageStatsManager { @Retention(RetentionPolicy.SOURCE) public @interface StandbyBuckets {} + /** + * Observer id of the registered observer for the group of packages that reached the usage + * time limit. Included as an extra in the PendingIntent that was registered. + * @hide + */ + @SystemApi + public static final String EXTRA_OBSERVER_ID = "android.app.usage.extra.OBSERVER_ID"; + + /** + * Original time limit in milliseconds specified by the registered observer for the group of + * packages that reached the usage time limit. Included as an extra in the PendingIntent that + * was registered. + * @hide + */ + @SystemApi + public static final String EXTRA_TIME_LIMIT = "android.app.usage.extra.TIME_LIMIT"; + + /** + * Actual usage time in milliseconds for the group of packages that reached the specified time + * limit. Included as an extra in the PendingIntent that was registered. + * @hide + */ + @SystemApi + public static final String EXTRA_TIME_USED = "android.app.usage.extra.TIME_USED"; + private static final UsageEvents sEmptyResults = new UsageEvents(); private final Context mContext; @@ -470,6 +497,53 @@ public final class UsageStatsManager { } } + /** + * @hide + * Register an app usage limit observer that receives a callback on the provided intent when + * the sum of usages of apps in the packages array exceeds the timeLimit specified. The + * observer will automatically be unregistered when the time limit is reached and the intent + * is delivered. + * @param observerId A unique id associated with the group of apps to be monitored. There can + * be multiple groups with common packages and different time limits. + * @param packages The list of packages to observe for foreground activity time. Must include + * at least one package. + * @param timeLimit The total time the set of apps can be in the foreground before the + * callbackIntent is delivered. Must be greater than 0. + * @param timeUnit The unit for time specified in timeLimit. + * @param callbackIntent The PendingIntent that will be dispatched when the time limit is + * exceeded by the group of apps. The delivered Intent will also contain + * the extras {@link #EXTRA_OBSERVER_ID}, {@link #EXTRA_TIME_LIMIT} and + * {@link #EXTRA_TIME_USED}. + * @throws SecurityException if the caller doesn't have the PACKAGE_USAGE_STATS permission. + */ + @SystemApi + @RequiresPermission(android.Manifest.permission.PACKAGE_USAGE_STATS) + public void registerAppUsageObserver(int observerId, String[] packages, long timeLimit, + TimeUnit timeUnit, PendingIntent callbackIntent) { + try { + mService.registerAppUsageObserver(observerId, packages, timeUnit.toMillis(timeLimit), + callbackIntent, mContext.getOpPackageName()); + } catch (RemoteException e) { + } + } + + /** + * @hide + * Unregister the app usage observer specified by the observerId. This will only apply to any + * observer registered by this application. Unregistering an observer that was already + * unregistered or never registered will have no effect. + * @param observerId The id of the observer that was previously registered. + * @throws SecurityException if the caller doesn't have the PACKAGE_USAGE_STATS permission. + */ + @SystemApi + @RequiresPermission(android.Manifest.permission.PACKAGE_USAGE_STATS) + public void unregisterAppUsageObserver(int observerId) { + try { + mService.unregisterAppUsageObserver(observerId, mContext.getOpPackageName()); + } catch (RemoteException e) { + } + } + /** @hide */ public static String reasonToString(int standbyReason) { StringBuilder sb = new StringBuilder(); diff --git a/services/tests/servicestests/src/com/android/server/usage/AppTimeLimitControllerTests.java b/services/tests/servicestests/src/com/android/server/usage/AppTimeLimitControllerTests.java new file mode 100644 index 000000000000..6b52ee5f4408 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/usage/AppTimeLimitControllerTests.java @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2018 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.usage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.app.PendingIntent; +import android.os.HandlerThread; +import android.os.Looper; +import android.support.test.filters.MediumTest; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +@RunWith(AndroidJUnit4.class) +@MediumTest +public class AppTimeLimitControllerTests { + + private static final String PKG_SOC1 = "package.soc1"; + private static final String PKG_SOC2 = "package.soc2"; + private static final String PKG_GAME1 = "package.game1"; + private static final String PKG_GAME2 = "package.game2"; + private static final String PKG_PROD = "package.prod"; + + private static final int UID = 10100; + private static final int USER_ID = 10; + private static final int OBS_ID1 = 1; + private static final int OBS_ID2 = 2; + private static final int OBS_ID3 = 3; + + private static final long TIME_30_MIN = 30 * 60_1000L; + private static final long TIME_10_MIN = 10 * 60_1000L; + + private static final String[] GROUP1 = { + PKG_SOC1, PKG_GAME1, PKG_PROD + }; + + private static final String[] GROUP_SOC = { + PKG_SOC1, PKG_SOC2 + }; + + private static final String[] GROUP_GAME = { + PKG_GAME1, PKG_GAME2 + }; + + private final CountDownLatch mCountDownLatch = new CountDownLatch(1); + + private AppTimeLimitController mController; + + private HandlerThread mThread; + + private long mUptimeMillis; + + AppTimeLimitController.OnLimitReachedListener mListener + = new AppTimeLimitController.OnLimitReachedListener() { + + @Override + public void onLimitReached(int observerId, int userId, long timeLimit, long timeElapsed, + PendingIntent callbackIntent) { + mCountDownLatch.countDown(); + } + }; + + class MyAppTimeLimitController extends AppTimeLimitController { + MyAppTimeLimitController(AppTimeLimitController.OnLimitReachedListener listener, + Looper looper) { + super(listener, looper); + } + + @Override + protected long getUptimeMillis() { + return mUptimeMillis; + } + } + + @Before + public void setUp() { + mThread = new HandlerThread("Test"); + mThread.start(); + mController = new MyAppTimeLimitController(mListener, mThread.getLooper()); + } + + @After + public void tearDown() { + mThread.quit(); + } + + /** Verify observer is added */ + @Test + public void testAddObserver() { + addObserver(OBS_ID1, GROUP1, TIME_30_MIN); + assertTrue("Observer wasn't added", hasObserver(OBS_ID1)); + addObserver(OBS_ID2, GROUP_GAME, TIME_30_MIN); + assertTrue("Observer wasn't added", hasObserver(OBS_ID2)); + assertTrue("Observer wasn't added", hasObserver(OBS_ID1)); + } + + /** Verify observer is removed */ + @Test + public void testRemoveObserver() { + addObserver(OBS_ID1, GROUP1, TIME_30_MIN); + assertTrue("Observer wasn't added", hasObserver(OBS_ID1)); + mController.removeObserver(UID, OBS_ID1, USER_ID); + assertFalse("Observer wasn't removed", hasObserver(OBS_ID1)); + } + + /** Re-adding an observer should result in only one copy */ + @Test + public void testObserverReAdd() { + addObserver(OBS_ID1, GROUP1, TIME_30_MIN); + assertTrue("Observer wasn't added", hasObserver(OBS_ID1)); + addObserver(OBS_ID1, GROUP1, TIME_10_MIN); + assertTrue("Observer wasn't added", + mController.getObserverGroup(OBS_ID1, USER_ID).timeLimit == TIME_10_MIN); + mController.removeObserver(UID, OBS_ID1, USER_ID); + assertFalse("Observer wasn't removed", hasObserver(OBS_ID1)); + } + + /** Verify that usage across different apps within a group are added up */ + @Test + public void testAccumulation() throws Exception { + setTime(0L); + addObserver(OBS_ID1, GROUP1, TIME_30_MIN); + moveToForeground(PKG_SOC1); + // Add 10 mins + setTime(TIME_10_MIN); + moveToBackground(PKG_SOC1); + + long timeRemaining = mController.getObserverGroup(OBS_ID1, USER_ID).timeRemaining; + assertEquals(TIME_10_MIN * 2, timeRemaining); + + moveToForeground(PKG_SOC1); + setTime(TIME_10_MIN * 2); + moveToBackground(PKG_SOC1); + + timeRemaining = mController.getObserverGroup(OBS_ID1, USER_ID).timeRemaining; + assertEquals(TIME_10_MIN, timeRemaining); + + setTime(TIME_30_MIN); + + assertFalse(mCountDownLatch.await(100L, TimeUnit.MILLISECONDS)); + + // Add a different package in the group + moveToForeground(PKG_GAME1); + setTime(TIME_30_MIN + TIME_10_MIN); + moveToBackground(PKG_GAME1); + + assertEquals(0, mController.getObserverGroup(OBS_ID1, USER_ID).timeRemaining); + assertTrue(mCountDownLatch.await(100L, TimeUnit.MILLISECONDS)); + } + + /** Verify that time limit does not get triggered due to a different app */ + @Test + public void testTimeoutOtherApp() throws Exception { + setTime(0L); + addObserver(OBS_ID1, GROUP1, 4_000L); + moveToForeground(PKG_SOC2); + assertFalse(mCountDownLatch.await(6_000L, TimeUnit.MILLISECONDS)); + setTime(6_000L); + moveToBackground(PKG_SOC2); + assertFalse(mCountDownLatch.await(100L, TimeUnit.MILLISECONDS)); + } + + /** Verify the timeout message is delivered at the right time */ + @Test + public void testTimeout() throws Exception { + setTime(0L); + addObserver(OBS_ID1, GROUP1, 4_000L); + moveToForeground(PKG_SOC1); + setTime(6_000L); + assertTrue(mCountDownLatch.await(6_000L, TimeUnit.MILLISECONDS)); + moveToBackground(PKG_SOC1); + // Verify that the observer was removed + assertFalse(hasObserver(OBS_ID1)); + } + + /** If an app was already running, make sure it is partially counted towards the time limit */ + @Test + public void testAlreadyRunning() throws Exception { + setTime(TIME_10_MIN); + moveToForeground(PKG_GAME1); + setTime(TIME_30_MIN); + addObserver(OBS_ID2, GROUP_GAME, TIME_30_MIN); + setTime(TIME_30_MIN + TIME_10_MIN); + moveToBackground(PKG_GAME1); + assertFalse(mCountDownLatch.await(1000L, TimeUnit.MILLISECONDS)); + + moveToForeground(PKG_GAME2); + setTime(TIME_30_MIN + TIME_30_MIN); + moveToBackground(PKG_GAME2); + assertTrue(mCountDownLatch.await(1000L, TimeUnit.MILLISECONDS)); + // Verify that the observer was removed + assertFalse(hasObserver(OBS_ID2)); + } + + /** If watched app is already running, verify the timeout callback happens at the right time */ + @Test + public void testAlreadyRunningTimeout() throws Exception { + setTime(0); + moveToForeground(PKG_SOC1); + setTime(TIME_10_MIN); + // 10 second time limit + addObserver(OBS_ID1, GROUP_SOC, 10_000L); + setTime(TIME_10_MIN + 5_000L); + // Shouldn't call back in 6 seconds + assertFalse(mCountDownLatch.await(6_000L, TimeUnit.MILLISECONDS)); + setTime(TIME_10_MIN + 10_000L); + // Should call back by 11 seconds (6 earlier + 5 now) + assertTrue(mCountDownLatch.await(5_000L, TimeUnit.MILLISECONDS)); + // Verify that the observer was removed + assertFalse(hasObserver(OBS_ID1)); + } + + private void moveToForeground(String packageName) { + mController.moveToForeground(packageName, "class", USER_ID); + } + + private void moveToBackground(String packageName) { + mController.moveToBackground(packageName, "class", USER_ID); + } + + private void addObserver(int observerId, String[] packages, long timeLimit) { + mController.addObserver(UID, observerId, packages, timeLimit, null, USER_ID); + } + + /** Is there still an observer by that id */ + private boolean hasObserver(int observerId) { + return mController.getObserverGroup(observerId, USER_ID) != null; + } + + private void setTime(long time) { + mUptimeMillis = time; + } +} diff --git a/services/usage/java/com/android/server/usage/AppTimeLimitController.java b/services/usage/java/com/android/server/usage/AppTimeLimitController.java new file mode 100644 index 000000000000..9cd05933d845 --- /dev/null +++ b/services/usage/java/com/android/server/usage/AppTimeLimitController.java @@ -0,0 +1,464 @@ +/** + * Copyright (C) 2018 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.usage; + +import android.annotation.UserIdInt; +import android.app.PendingIntent; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.SystemClock; +import android.text.TextUtils; +import android.util.ArrayMap; +import android.util.Slog; +import android.util.SparseArray; + +import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.util.ArrayUtils; + +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.Arrays; + +/** + * Monitors and informs of any app time limits exceeded. It must be informed when an app + * enters the foreground and exits. Used by UsageStatsService. Manages multiple users. + * + * Test: atest FrameworksServicesTests:AppTimeLimitControllerTests + * Test: manual: frameworks/base/tests/UsageStatsTest + */ +public class AppTimeLimitController { + + private static final String TAG = "AppTimeLimitController"; + + private static final boolean DEBUG = false; + + /** Lock class for this object */ + private static class Lock {} + + /** Lock object for the data in this class. */ + private final Lock mLock = new Lock(); + + private final MyHandler mHandler; + + private OnLimitReachedListener mListener; + + @GuardedBy("mLock") + private final SparseArray<UserData> mUsers = new SparseArray<>(); + + private static class UserData { + /** userId of the user */ + private @UserIdInt int userId; + + /** The app that is currently in the foreground */ + private String currentForegroundedPackage; + + /** The time when the current app came to the foreground */ + private long currentForegroundedTime; + + /** The last app that was in the background */ + private String lastBackgroundedPackage; + + /** Map from package name for quick lookup */ + private ArrayMap<String, ArrayList<TimeLimitGroup>> packageMap = new ArrayMap<>(); + + /** Map of observerId to details of the time limit group */ + private SparseArray<TimeLimitGroup> groups = new SparseArray<>(); + + UserData(@UserIdInt int userId) { + this.userId = userId; + } + } + + /** + * Listener interface for being informed when an app group's time limit is reached. + */ + public interface OnLimitReachedListener { + /** + * Time limit for a group, keyed by the observerId, has been reached. + * @param observerId The observerId of the group whose limit was reached + * @param userId The userId + * @param timeLimit The original time limit in milliseconds + * @param timeElapsed How much time was actually spent on apps in the group, in milliseconds + * @param callbackIntent The PendingIntent to send when the limit is reached + */ + public void onLimitReached(int observerId, @UserIdInt int userId, long timeLimit, + long timeElapsed, PendingIntent callbackIntent); + } + + static class TimeLimitGroup { + int requestingUid; + int observerId; + String[] packages; + long timeLimit; + long timeRequested; + long timeRemaining; + PendingIntent callbackIntent; + String currentPackage; + long timeCurrentPackageStarted; + int userId; + } + + class MyHandler extends Handler { + + static final int MSG_CHECK_TIMEOUT = 1; + static final int MSG_INFORM_LISTENER = 2; + + MyHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case MSG_CHECK_TIMEOUT: + checkTimeout((TimeLimitGroup) msg.obj); + break; + case MSG_INFORM_LISTENER: + informListener((TimeLimitGroup) msg.obj); + break; + default: + super.handleMessage(msg); + break; + } + } + } + + public AppTimeLimitController(OnLimitReachedListener listener, Looper looper) { + mHandler = new MyHandler(looper); + mListener = listener; + } + + /** Overrideable by a test */ + @VisibleForTesting + protected long getUptimeMillis() { + return SystemClock.uptimeMillis(); + } + + /** Returns an existing UserData object for the given userId, or creates one */ + UserData getOrCreateUserDataLocked(int userId) { + UserData userData = mUsers.get(userId); + if (userData == null) { + userData = new UserData(userId); + mUsers.put(userId, userData); + } + return userData; + } + + /** Clean up data if user is removed */ + public void onUserRemoved(int userId) { + synchronized (mLock) { + // TODO: Remove any inflight delayed messages + mUsers.remove(userId); + } + } + + /** + * Registers an observer with the given details. Existing observer with the same observerId + * is removed. + */ + public void addObserver(int requestingUid, int observerId, String[] packages, long timeLimit, + PendingIntent callbackIntent, @UserIdInt int userId) { + synchronized (mLock) { + UserData user = getOrCreateUserDataLocked(userId); + + removeObserverLocked(user, requestingUid, observerId); + + TimeLimitGroup group = new TimeLimitGroup(); + group.observerId = observerId; + group.callbackIntent = callbackIntent; + group.packages = packages; + group.timeLimit = timeLimit; + group.timeRemaining = group.timeLimit; + group.timeRequested = getUptimeMillis(); + group.requestingUid = requestingUid; + group.timeCurrentPackageStarted = -1L; + group.userId = userId; + + user.groups.append(observerId, group); + + addGroupToPackageMapLocked(user, packages, group); + + if (DEBUG) { + Slog.d(TAG, "addObserver " + packages + " for " + timeLimit); + } + // Handle the case where a target package is already in the foreground when observer + // is added. + if (user.currentForegroundedPackage != null && inPackageList(group.packages, + user.currentForegroundedPackage)) { + group.timeCurrentPackageStarted = group.timeRequested; + group.currentPackage = user.currentForegroundedPackage; + if (group.timeRemaining > 0) { + postCheckTimeoutLocked(group, group.timeRemaining); + } + } + } + } + + /** + * Remove a registered observer by observerId and calling uid. + * @param requestingUid The calling uid + * @param observerId The unique observer id for this user + * @param userId The user id of the observer + */ + public void removeObserver(int requestingUid, int observerId, @UserIdInt int userId) { + synchronized (mLock) { + UserData user = getOrCreateUserDataLocked(userId); + removeObserverLocked(user, requestingUid, observerId); + } + } + + @VisibleForTesting + TimeLimitGroup getObserverGroup(int observerId, int userId) { + synchronized (mLock) { + return getOrCreateUserDataLocked(userId).groups.get(observerId); + } + } + + private static boolean inPackageList(String[] packages, String packageName) { + return ArrayUtils.contains(packages, packageName); + } + + @GuardedBy("mLock") + private void removeObserverLocked(UserData user, int requestingUid, int observerId) { + TimeLimitGroup group = user.groups.get(observerId); + if (group != null && group.requestingUid == requestingUid) { + removeGroupFromPackageMapLocked(user, group); + user.groups.remove(observerId); + mHandler.removeMessages(MyHandler.MSG_CHECK_TIMEOUT, group); + } + } + + /** + * Called when an app has moved to the foreground. + * @param packageName The app that is foregrounded + * @param className The className of the activity + * @param userId The user + */ + public void moveToForeground(String packageName, String className, int userId) { + synchronized (mLock) { + UserData user = getOrCreateUserDataLocked(userId); + if (DEBUG) Slog.d(TAG, "Setting mCurrentForegroundedPackage to " + packageName); + // Note the current foreground package + user.currentForegroundedPackage = packageName; + user.currentForegroundedTime = getUptimeMillis(); + + // Check if the last package that was backgrounded is the same as this one + if (!TextUtils.equals(packageName, user.lastBackgroundedPackage)) { + // TODO: Move this logic up to usage stats to persist there. + incTotalLaunchesLocked(user, packageName); + } + + // Check if any of the groups need to watch for this package + maybeWatchForPackageLocked(user, packageName, user.currentForegroundedTime); + } + } + + /** + * Called when an app is sent to the background. + * + * @param packageName + * @param className + * @param userId + */ + public void moveToBackground(String packageName, String className, int userId) { + synchronized (mLock) { + UserData user = getOrCreateUserDataLocked(userId); + user.lastBackgroundedPackage = packageName; + if (!TextUtils.equals(user.currentForegroundedPackage, packageName)) { + Slog.w(TAG, "Eh? Last foregrounded package = " + user.currentForegroundedPackage + + " and now backgrounded = " + packageName); + return; + } + final long stopTime = getUptimeMillis(); + + // Add up the usage time to all groups that contain the package + ArrayList<TimeLimitGroup> groups = user.packageMap.get(packageName); + if (groups != null) { + final int size = groups.size(); + for (int i = 0; i < size; i++) { + final TimeLimitGroup group = groups.get(i); + // Don't continue to send + if (group.timeRemaining <= 0) continue; + + final long startTime = Math.max(user.currentForegroundedTime, + group.timeRequested); + long diff = stopTime - startTime; + group.timeRemaining -= diff; + if (group.timeRemaining <= 0) { + if (DEBUG) Slog.d(TAG, "MTB informing group obs=" + group.observerId); + postInformListenerLocked(group); + } + // Reset indicators that observer was added when package was already fg + group.currentPackage = null; + group.timeCurrentPackageStarted = -1L; + mHandler.removeMessages(MyHandler.MSG_CHECK_TIMEOUT, group); + } + } + user.currentForegroundedPackage = null; + } + } + + private void postInformListenerLocked(TimeLimitGroup group) { + mHandler.sendMessage(mHandler.obtainMessage(MyHandler.MSG_INFORM_LISTENER, + group)); + } + + /** + * Inform the observer and unregister it, as the limit has been reached. + * @param group the observed group + */ + private void informListener(TimeLimitGroup group) { + if (mListener != null) { + mListener.onLimitReached(group.observerId, group.userId, group.timeLimit, + group.timeLimit - group.timeRemaining, group.callbackIntent); + } + // Unregister since the limit has been met and observer was informed. + synchronized (mLock) { + UserData user = getOrCreateUserDataLocked(group.userId); + removeObserverLocked(user, group.requestingUid, group.observerId); + } + } + + /** Check if any of the groups care about this package and set up delayed messages */ + @GuardedBy("mLock") + private void maybeWatchForPackageLocked(UserData user, String packageName, long uptimeMillis) { + ArrayList<TimeLimitGroup> groups = user.packageMap.get(packageName); + if (groups == null) return; + + final int size = groups.size(); + for (int i = 0; i < size; i++) { + TimeLimitGroup group = groups.get(i); + if (group.timeRemaining > 0) { + group.timeCurrentPackageStarted = uptimeMillis; + group.currentPackage = packageName; + if (DEBUG) { + Slog.d(TAG, "Posting timeout for " + packageName + " for " + + group.timeRemaining + "ms"); + } + postCheckTimeoutLocked(group, group.timeRemaining); + } + } + } + + private void addGroupToPackageMapLocked(UserData user, String[] packages, + TimeLimitGroup group) { + for (int i = 0; i < packages.length; i++) { + ArrayList<TimeLimitGroup> list = user.packageMap.get(packages[i]); + if (list == null) { + list = new ArrayList<>(); + user.packageMap.put(packages[i], list); + } + list.add(group); + } + } + + /** + * Remove the group reference from the package to group mapping, which is 1 to many. + * @param group The group to remove from the package map. + */ + private void removeGroupFromPackageMapLocked(UserData user, TimeLimitGroup group) { + final int mapSize = user.packageMap.size(); + for (int i = 0; i < mapSize; i++) { + ArrayList<TimeLimitGroup> list = user.packageMap.valueAt(i); + list.remove(group); + } + } + + private void postCheckTimeoutLocked(TimeLimitGroup group, long timeout) { + mHandler.sendMessageDelayed(mHandler.obtainMessage(MyHandler.MSG_CHECK_TIMEOUT, group), + timeout); + } + + /** + * See if the given group has reached the timeout if the current foreground app is included + * and it exceeds the time remaining. + * @param group the group of packages to check + */ + void checkTimeout(TimeLimitGroup group) { + // For each package in the group, check if any of the currently foregrounded apps are adding + // up to hit the limit and inform the observer + synchronized (mLock) { + UserData user = getOrCreateUserDataLocked(group.userId); + // This group doesn't exist anymore, nothing to see here. + if (user.groups.get(group.observerId) != group) return; + + if (DEBUG) Slog.d(TAG, "checkTimeout timeRemaining=" + group.timeRemaining); + + // Already reached the limit, no need to report again + if (group.timeRemaining <= 0) return; + + if (DEBUG) { + Slog.d(TAG, "checkTimeout foregroundedPackage=" + + user.currentForegroundedPackage); + } + + if (inPackageList(group.packages, user.currentForegroundedPackage)) { + if (DEBUG) { + Slog.d(TAG, "checkTimeout package in foreground=" + + user.currentForegroundedPackage); + } + if (group.timeCurrentPackageStarted < 0) { + Slog.w(TAG, "startTime was not set correctly for " + group); + } + final long timeInForeground = getUptimeMillis() - group.timeCurrentPackageStarted; + if (group.timeRemaining <= timeInForeground) { + if (DEBUG) Slog.d(TAG, "checkTimeout : Time limit reached"); + // Hit the limit, set timeRemaining to zero to avoid checking again + group.timeRemaining -= timeInForeground; + postInformListenerLocked(group); + // Reset + group.timeCurrentPackageStarted = -1L; + group.currentPackage = null; + } else { + if (DEBUG) Slog.d(TAG, "checkTimeout : Some more time remaining"); + postCheckTimeoutLocked(group, group.timeRemaining - timeInForeground); + } + } + } + } + + private void incTotalLaunchesLocked(UserData user, String packageName) { + // TODO: Inform UsageStatsService and aggregate the counter per app + } + + void dump(PrintWriter pw) { + synchronized (mLock) { + pw.println("\n App Time Limits"); + int nUsers = mUsers.size(); + for (int i = 0; i < nUsers; i++) { + UserData user = mUsers.valueAt(i); + pw.print(" User "); pw.println(user.userId); + int nGroups = user.groups.size(); + for (int j = 0; j < nGroups; j++) { + TimeLimitGroup group = user.groups.valueAt(j); + pw.print(" Group id="); pw.print(group.observerId); + pw.print(" timeLimit="); pw.print(group.timeLimit); + pw.print(" remaining="); pw.print(group.timeRemaining); + pw.print(" currentPackage="); pw.print(group.currentPackage); + pw.print(" timeCurrentPkgStarted="); pw.print(group.timeCurrentPackageStarted); + pw.print(" packages="); pw.println(Arrays.toString(group.packages)); + } + pw.println(); + pw.print(" currentForegroundedPackage="); + pw.println(user.currentForegroundedPackage); + pw.print(" lastBackgroundedPackage="); pw.println(user.lastBackgroundedPackage); + } + } + } +} diff --git a/services/usage/java/com/android/server/usage/UsageStatsService.java b/services/usage/java/com/android/server/usage/UsageStatsService.java index a30257890c3c..e3d5b692cf4b 100644 --- a/services/usage/java/com/android/server/usage/UsageStatsService.java +++ b/services/usage/java/com/android/server/usage/UsageStatsService.java @@ -20,6 +20,7 @@ import android.Manifest; import android.app.ActivityManager; import android.app.AppOpsManager; import android.app.IUidObserver; +import android.app.PendingIntent; import android.app.usage.AppStandbyInfo; import android.app.usage.ConfigurationStats; import android.app.usage.IUsageStatsManager; @@ -72,6 +73,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeUnit; /** * A service that collects, aggregates, and persists application usage data. @@ -117,6 +119,8 @@ public class UsageStatsService extends SystemService implements AppStandbyController mAppStandby; + AppTimeLimitController mAppTimeLimit; + private UsageStatsManagerInternal.AppIdleStateChangeListener mStandbyChangeListener = new UsageStatsManagerInternal.AppIdleStateChangeListener() { @Override @@ -151,6 +155,20 @@ public class UsageStatsService extends SystemService implements mAppStandby = new AppStandbyController(getContext(), BackgroundThread.get().getLooper()); + mAppTimeLimit = new AppTimeLimitController( + (observerId, userId, timeLimit, timeElapsed, callbackIntent) -> { + Intent intent = new Intent(); + intent.putExtra(UsageStatsManager.EXTRA_OBSERVER_ID, observerId); + intent.putExtra(UsageStatsManager.EXTRA_TIME_LIMIT, timeLimit); + intent.putExtra(UsageStatsManager.EXTRA_TIME_USED, timeElapsed); + try { + callbackIntent.send(getContext(), 0, intent); + } catch (PendingIntent.CanceledException e) { + Slog.w(TAG, "Couldn't deliver callback: " + + callbackIntent); + } + }, mHandler.getLooper()); + mAppStandby.addListener(mStandbyChangeListener); File systemDataDir = new File(Environment.getDataDirectory(), "system"); mUsageStatsDir = new File(systemDataDir, "usagestats"); @@ -374,6 +392,16 @@ public class UsageStatsService extends SystemService implements service.reportEvent(event); mAppStandby.reportEvent(event, elapsedRealtime, userId); + switch (event.mEventType) { + case Event.MOVE_TO_FOREGROUND: + mAppTimeLimit.moveToForeground(event.getPackageName(), event.getClassName(), + userId); + break; + case Event.MOVE_TO_BACKGROUND: + mAppTimeLimit.moveToBackground(event.getPackageName(), event.getClassName(), + userId); + break; + } } } @@ -394,6 +422,7 @@ public class UsageStatsService extends SystemService implements Slog.i(TAG, "Removing user " + userId + " and all data."); mUserState.remove(userId); mAppStandby.onUserRemoved(userId); + mAppTimeLimit.onUserRemoved(userId); cleanUpRemovedUsersLocked(); } } @@ -545,6 +574,8 @@ public class UsageStatsService extends SystemService implements pw.println(); mAppStandby.dumpState(args, pw); } + + mAppTimeLimit.dump(pw); } } @@ -923,6 +954,60 @@ public class UsageStatsService extends SystemService implements mHandler.obtainMessage(MSG_REPORT_EVENT, userId, 0, event).sendToTarget(); } + + @Override + public void registerAppUsageObserver(int observerId, + String[] packages, long timeLimitMs, PendingIntent + callbackIntent, String callingPackage) { + if (!hasPermission(callingPackage)) { + throw new SecurityException("Caller doesn't have PACKAGE_USAGE_STATS permission"); + } + + if (packages == null || packages.length == 0) { + throw new IllegalArgumentException("Must specify at least one package"); + } + if (timeLimitMs <= 0) { + throw new IllegalArgumentException("Time limit must be > 0"); + } + if (callbackIntent == null) { + throw new NullPointerException("callbackIntent can't be null"); + } + final int callingUid = Binder.getCallingUid(); + final int userId = UserHandle.getUserId(callingUid); + final long token = Binder.clearCallingIdentity(); + try { + UsageStatsService.this.registerAppUsageObserver(callingUid, observerId, + packages, timeLimitMs, callbackIntent, userId); + } finally { + Binder.restoreCallingIdentity(token); + } + } + + @Override + public void unregisterAppUsageObserver(int observerId, String callingPackage) { + if (!hasPermission(callingPackage)) { + throw new SecurityException("Caller doesn't have PACKAGE_USAGE_STATS permission"); + } + + final int callingUid = Binder.getCallingUid(); + final int userId = UserHandle.getUserId(callingUid); + final long token = Binder.clearCallingIdentity(); + try { + UsageStatsService.this.unregisterAppUsageObserver(callingUid, observerId, userId); + } finally { + Binder.restoreCallingIdentity(token); + } + } + } + + void registerAppUsageObserver(int callingUid, int observerId, String[] packages, + long timeLimitMs, PendingIntent callbackIntent, int userId) { + mAppTimeLimit.addObserver(callingUid, observerId, packages, timeLimitMs, callbackIntent, + userId); + } + + void unregisterAppUsageObserver(int callingUid, int observerId, int userId) { + mAppTimeLimit.removeObserver(callingUid, observerId, userId); } /** diff --git a/tests/UsageStatsTest/AndroidManifest.xml b/tests/UsageStatsTest/AndroidManifest.xml index c27be7b2d5bf..66af45424fba 100644 --- a/tests/UsageStatsTest/AndroidManifest.xml +++ b/tests/UsageStatsTest/AndroidManifest.xml @@ -21,5 +21,6 @@ </activity> <activity android:name=".UsageLogActivity" /> + </application> </manifest> diff --git a/tests/UsageStatsTest/res/menu/main.xml b/tests/UsageStatsTest/res/menu/main.xml index 4ccbc81ab317..612267c85b1b 100644 --- a/tests/UsageStatsTest/res/menu/main.xml +++ b/tests/UsageStatsTest/res/menu/main.xml @@ -4,4 +4,6 @@ android:title="View Log"/> <item android:id="@+id/call_is_app_inactive" android:title="Call isAppInactive()"/> + <item android:id="@+id/set_app_limit" + android:title="Set App Limit" /> </menu> diff --git a/tests/UsageStatsTest/src/com/android/tests/usagestats/UsageStatsActivity.java b/tests/UsageStatsTest/src/com/android/tests/usagestats/UsageStatsActivity.java index 9429d9bbf89b..3d8ce21a2c00 100644 --- a/tests/UsageStatsTest/src/com/android/tests/usagestats/UsageStatsActivity.java +++ b/tests/UsageStatsTest/src/com/android/tests/usagestats/UsageStatsActivity.java @@ -18,6 +18,7 @@ package com.android.tests.usagestats; import android.app.AlertDialog; import android.app.ListActivity; +import android.app.PendingIntent; import android.app.usage.UsageStats; import android.app.usage.UsageStatsManager; import android.content.Context; @@ -36,14 +37,17 @@ import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.EditText; import android.widget.TextView; +import android.widget.Toast; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Map; +import java.util.concurrent.TimeUnit; public class UsageStatsActivity extends ListActivity { private static final long USAGE_STATS_PERIOD = 1000 * 60 * 60 * 24 * 14; + private static final String EXTRA_KEY_TIMEOUT = "com.android.tests.usagestats.extra.TIMEOUT"; private UsageStatsManager mUsageStatsManager; private Adapter mAdapter; private Comparator<UsageStats> mComparator = new Comparator<UsageStats>() { @@ -59,6 +63,20 @@ public class UsageStatsActivity extends ListActivity { mUsageStatsManager = (UsageStatsManager) getSystemService(Context.USAGE_STATS_SERVICE); mAdapter = new Adapter(); setListAdapter(mAdapter); + Bundle extras = getIntent().getExtras(); + if (extras != null && extras.containsKey(UsageStatsManager.EXTRA_TIME_USED)) { + System.err.println("UsageStatsActivity " + extras); + Toast.makeText(this, "Timeout of observed app\n" + extras, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onNewIntent(Intent intent) { + Bundle extras = intent.getExtras(); + if (extras != null && extras.containsKey(UsageStatsManager.EXTRA_TIME_USED)) { + System.err.println("UsageStatsActivity " + extras); + Toast.makeText(this, "Timeout of observed app\n" + extras, Toast.LENGTH_SHORT).show(); + } } @Override @@ -77,7 +95,9 @@ public class UsageStatsActivity extends ListActivity { case R.id.call_is_app_inactive: callIsAppInactive(); return true; - + case R.id.set_app_limit: + callSetAppLimit(); + return true; default: return super.onOptionsItemSelected(item); } @@ -116,6 +136,40 @@ public class UsageStatsActivity extends ListActivity { builder.show(); } + private void callSetAppLimit() { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("Enter package name"); + final EditText input = new EditText(this); + input.setInputType(InputType.TYPE_CLASS_TEXT); + input.setHint("com.android.tests.usagestats"); + builder.setView(input); + + builder.setPositiveButton("OK", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + final String packageName = input.getText().toString().trim(); + if (!TextUtils.isEmpty(packageName)) { + String[] packages = packageName.split(","); + Intent intent = new Intent(Intent.ACTION_MAIN); + intent.setClass(UsageStatsActivity.this, UsageStatsActivity.class); + intent.setPackage(getPackageName()); + intent.putExtra(EXTRA_KEY_TIMEOUT, true); + mUsageStatsManager.registerAppUsageObserver(1, packages, + 30, TimeUnit.SECONDS, PendingIntent.getActivity(UsageStatsActivity.this, + 1, intent, 0)); + } + } + }); + builder.setNegativeButton("Cancel", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.cancel(); + } + }); + + builder.show(); + } + private void showInactive(String packageName) { final AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setMessage( |