From ae2f2b5268e19423b85a5a8da86fc559b74143d6 Mon Sep 17 00:00:00 2001 From: fanzhang172 Date: Mon, 1 May 2017 19:15:17 -0700 Subject: [PATCH] Add recent apps in app & notification - Introduce a RecentAppsPreferenceControler, which queries UsageStatsManager and displays a list of recently used apps. - Add a control flag for this feature, intially set to false. - Make ManageApplications a static pref item instead of dynamic one. This makes the RecentAppController easier to control "See all" preference, which is backed by ManageApplications. - Also adjust app_items.xml layout to make app item UI consistent with preference item. Change-Id: I0b9e1784faed32b3055ebf96ef98b6a5e422de50 Fix: 33265548 Test: robotests --- AndroidManifest.xml | 5 - res/drawable/ic_chevron_right_24dp.xml | 27 ++ res/layout/app_item.xml | 8 +- res/values/config.xml | 3 + res/values/strings.xml | 4 + res/xml/app_and_notification.xml | 21 ++ .../AppAndNotificationDashboardFragment.java | 18 +- .../android/settings/applications/AppCounter.java | 3 +- .../RecentAppsPreferenceController.java | 312 +++++++++++++++++++++ .../RecentAppsPreferenceControllerTest.java | 209 ++++++++++++++ 10 files changed, 596 insertions(+), 14 deletions(-) create mode 100644 res/drawable/ic_chevron_right_24dp.xml create mode 100644 src/com/android/settings/applications/RecentAppsPreferenceController.java create mode 100644 tests/robotests/src/com/android/settings/applications/RecentAppsPreferenceControllerTest.java diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 7abdf278a3..c1bf7e5d19 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -958,11 +958,6 @@ - - - - + + + + diff --git a/res/layout/app_item.xml b/res/layout/app_item.xml index 15a901427c..d53afc965e 100644 --- a/res/layout/app_item.xml +++ b/res/layout/app_item.xml @@ -18,16 +18,16 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" - android:paddingTop="16dp" - android:paddingBottom="16dp" + android:paddingTop="12dp" + android:paddingBottom="12dp" android:gravity="top" android:columnCount="3" android:duplicateParentState="true"> true + + false + diff --git a/res/values/strings.xml b/res/values/strings.xml index 81e3b0fc36..b3f1e40207 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -3470,6 +3470,10 @@ Unknown sources Allow all app sources + + Recently used apps + + See all apps diff --git a/res/xml/app_and_notification.xml b/res/xml/app_and_notification.xml index 639735dcb5..9ddff85aa3 100644 --- a/res/xml/app_and_notification.xml +++ b/res/xml/app_and_notification.xml @@ -20,6 +20,27 @@ xmlns:settings="http://schemas.android.com/apk/res/com.android.settings" android:title="@string/app_and_notification_dashboard_title"> + + + + + + + + + + + getPreferenceControllers(Context context) { - return buildPreferenceControllers(context); + final Activity activity = getActivity(); + final Application app; + if (activity != null) { + app = activity.getApplication(); + } else { + app = null; + } + return buildPreferenceControllers(context, app, this); } - private static List buildPreferenceControllers(Context context) { + private static List buildPreferenceControllers(Context context, + Application app, Fragment host) { final List controllers = new ArrayList<>(); controllers.add(new SpecialAppAccessPreferenceController(context)); controllers.add(new AppPermissionsPreferenceController(context)); + controllers.add(new RecentAppsPreferenceController(context, app, host)); return controllers; } @@ -78,7 +90,7 @@ public class AppAndNotificationDashboardFragment extends DashboardFragment { @Override public List getPreferenceControllers(Context context) { - return buildPreferenceControllers(context); + return buildPreferenceControllers(context, null, null /* host */); } }; } diff --git a/src/com/android/settings/applications/AppCounter.java b/src/com/android/settings/applications/AppCounter.java index 8758b1453b..8c7aed7aef 100644 --- a/src/com/android/settings/applications/AppCounter.java +++ b/src/com/android/settings/applications/AppCounter.java @@ -14,7 +14,6 @@ package com.android.settings.applications; -import android.app.AppGlobals; import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; @@ -32,7 +31,7 @@ public abstract class AppCounter extends AsyncTask { public AppCounter(Context context, PackageManagerWrapper packageManager) { mPm = packageManager; - mUm = UserManager.get(context); + mUm = (UserManager) context.getSystemService(Context.USER_SERVICE); } @Override diff --git a/src/com/android/settings/applications/RecentAppsPreferenceController.java b/src/com/android/settings/applications/RecentAppsPreferenceController.java new file mode 100644 index 0000000000..4ff2dbf569 --- /dev/null +++ b/src/com/android/settings/applications/RecentAppsPreferenceController.java @@ -0,0 +1,312 @@ +/* + * 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.settings.applications; + +import android.app.Application; +import android.app.Fragment; +import android.app.usage.UsageStats; +import android.app.usage.UsageStatsManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.UserHandle; +import android.support.annotation.VisibleForTesting; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceCategory; +import android.support.v7.preference.PreferenceScreen; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.util.ArrayMap; +import android.util.ArraySet; +import android.util.IconDrawableFactory; +import android.util.Log; + +import com.android.settings.R; +import com.android.settings.core.PreferenceController; +import com.android.settingslib.applications.ApplicationsState; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static com.android.internal.logging.nano.MetricsProto.MetricsEvent + .SETTINGS_APP_NOTIF_CATEGORY; + +/** + * This controller displays a list of recently used apps and a "See all" button. If there is + * no recently used app, "See all" will be displayed as "App info". + */ +public class RecentAppsPreferenceController extends PreferenceController + implements Comparator { + + private static final String TAG = "RecentAppsCtrl"; + private static final String KEY_PREF_CATEGORY = "recent_apps_category"; + @VisibleForTesting + static final String KEY_SEE_ALL = "all_app_info"; + private static final int SHOW_RECENT_APP_COUNT = 5; + private static final Set SKIP_SYSTEM_PACKAGES = new ArraySet<>(); + + private final Fragment mHost; + private final PackageManager mPm; + private final UsageStatsManager mUsageStatsManager; + private final ApplicationsState mApplicationsState; + private final int mUserId; + private final IconDrawableFactory mIconDrawableFactory; + + private Calendar mCal; + private List mStats; + + private PreferenceCategory mCategory; + private Preference mSeeAllPref; + + static { + SKIP_SYSTEM_PACKAGES.addAll(Arrays.asList( + "android", + "com.android.phone", + "com.android.settings", + "com.android.systemui", + "com.android.providers.calendar", + "com.android.providers.media" + )); + } + + public RecentAppsPreferenceController(Context context, Application app, Fragment host) { + this(context, app == null ? null : ApplicationsState.getInstance(app), host); + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + RecentAppsPreferenceController(Context context, ApplicationsState appState, Fragment host) { + super(context); + mIconDrawableFactory = IconDrawableFactory.newInstance(context); + mUserId = UserHandle.myUserId(); + mPm = context.getPackageManager(); + mHost = host; + mUsageStatsManager = + (UsageStatsManager) context.getSystemService(Context.USAGE_STATS_SERVICE); + mApplicationsState = appState; + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public String getPreferenceKey() { + return KEY_PREF_CATEGORY; + } + + @Override + public void updateNonIndexableKeys(List keys) { + super.updateNonIndexableKeys(keys); + // Don't index category name into search. It's not actionable. + keys.add(KEY_PREF_CATEGORY); + } + + @Override + public void displayPreference(PreferenceScreen screen) { + mCategory = (PreferenceCategory) screen.findPreference(getPreferenceKey()); + mSeeAllPref = screen.findPreference(KEY_SEE_ALL); + super.displayPreference(screen); + refreshUi(mCategory.getContext()); + } + + @Override + public void updateState(Preference preference) { + super.updateState(preference); + // Show total number of installed apps as See all's summary. + new InstalledAppCounter(mContext, InstalledAppCounter.IGNORE_INSTALL_REASON, + new PackageManagerWrapperImpl(mContext.getPackageManager())) { + @Override + protected void onCountComplete(int num) { + mSeeAllPref.setSummary(mContext.getString(R.string.apps_summary, num)); + } + }.execute(); + refreshUi(mCategory.getContext()); + } + + @Override + public final int compare(UsageStats a, UsageStats b) { + // return by descending order + return Long.compare(b.getLastTimeUsed(), a.getLastTimeUsed()); + } + + @VisibleForTesting + void refreshUi(Context prefContext) { + reloadData(); + if (shouldDisplayRecentApps()) { + displayRecentApps(prefContext); + } else { + displayOnlyAppInfo(); + } + } + + @VisibleForTesting + void reloadData() { + mCal = Calendar.getInstance(); + mCal.add(Calendar.DAY_OF_YEAR, -1); + mStats = mUsageStatsManager.queryUsageStats( + UsageStatsManager.INTERVAL_BEST, mCal.getTimeInMillis(), + System.currentTimeMillis()); + } + + private void displayOnlyAppInfo() { + mCategory.setTitle(null); + mSeeAllPref.setTitle(R.string.applications_settings); + mSeeAllPref.setIcon(null); + int prefCount = mCategory.getPreferenceCount(); + for (int i = prefCount - 1; i >= 0; i--) { + final Preference pref = mCategory.getPreference(i); + if (!TextUtils.equals(pref.getKey(), KEY_SEE_ALL)) { + mCategory.removePreference(pref); + } + } + } + + private void displayRecentApps(Context prefContext) { + mCategory.setTitle(R.string.recent_app_category_title); + mSeeAllPref.setTitle(R.string.see_all_apps_title); + mSeeAllPref.setIcon(R.drawable.ic_chevron_right_24dp); + final List recentApps = getDisplayableRecentAppList(); + + // Rebind prefs/avoid adding new prefs if possible. Adding/removing prefs causes jank. + // Build a cached preference pool + final Map appPreferences = new ArrayMap<>(); + int prefCount = mCategory.getPreferenceCount(); + for (int i = 0; i < prefCount; i++) { + final Preference pref = mCategory.getPreference(i); + final String key = pref.getKey(); + if (!TextUtils.equals(key, KEY_SEE_ALL)) { + appPreferences.put(key, pref); + } + } + final int recentAppsCount = recentApps.size(); + for (int i = 0; i < recentAppsCount; i++) { + final UsageStats stat = recentApps.get(i); + // Bind recent apps to existing prefs if possible, or create a new pref. + final String pkgName = stat.getPackageName(); + final ApplicationsState.AppEntry appEntry = + mApplicationsState.getEntry(pkgName, mUserId); + if (appEntry == null) { + continue; + } + + boolean rebindPref = true; + Preference pref = appPreferences.remove(pkgName); + if (pref == null) { + pref = new Preference(prefContext); + rebindPref = false; + } + pref.setKey(pkgName); + pref.setTitle(appEntry.label); + pref.setIcon(mIconDrawableFactory.getBadgedIcon(appEntry.info)); + pref.setSummary(DateUtils.getRelativeTimeSpanString(stat.getLastTimeUsed(), + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE)); + pref.setOrder(i); + pref.setOnPreferenceClickListener(preference -> { + AppInfoBase.startAppInfoFragment(InstalledAppDetails.class, + R.string.application_info_label, pkgName, appEntry.info.uid, mHost, + 1001 /*RequestCode*/, SETTINGS_APP_NOTIF_CATEGORY); + return true; + }); + if (!rebindPref) { + mCategory.addPreference(pref); + } + } + // Remove unused prefs from pref cache pool + for (Preference unusedPrefs : appPreferences.values()) { + mCategory.removePreference(unusedPrefs); + } + } + + private List getDisplayableRecentAppList() { + final List recentApps = new ArrayList<>(); + final Map map = new ArrayMap<>(); + final int statCount = mStats.size(); + for (int i = 0; i < statCount; i++) { + final UsageStats pkgStats = mStats.get(i); + if (!shouldIncludePkgInRecents(pkgStats)) { + continue; + } + final String pkgName = pkgStats.getPackageName(); + final UsageStats existingStats = map.get(pkgName); + if (existingStats == null) { + map.put(pkgName, pkgStats); + } else { + existingStats.add(pkgStats); + } + } + final List packageStats = new ArrayList<>(); + packageStats.addAll(map.values()); + Collections.sort(packageStats, this /* comparator */); + int count = 0; + for (UsageStats stat : packageStats) { + final ApplicationsState.AppEntry appEntry = mApplicationsState.getEntry( + stat.getPackageName(), mUserId); + if (appEntry == null) { + continue; + } + recentApps.add(stat); + count++; + if (count >= SHOW_RECENT_APP_COUNT) { + break; + } + } + return recentApps; + } + + /** + * Whether or not we should show a list of recent apps, and a see all link. + */ + @VisibleForTesting + boolean shouldDisplayRecentApps() { + return mContext.getResources().getBoolean(R.bool.config_display_recent_apps) + && mApplicationsState != null && mStats != null && !mStats.isEmpty(); + } + + /** + * Whether or not the app should be included in recent list. + */ + private boolean shouldIncludePkgInRecents(UsageStats stat) { + final String pkgName = stat.getPackageName(); + if (stat.getLastTimeUsed() < mCal.getTimeInMillis()) { + Log.d(TAG, "Invalid timestamp, skipping " + pkgName); + return false; + } + + if (SKIP_SYSTEM_PACKAGES.contains(pkgName)) { + Log.d(TAG, "System package, skipping " + pkgName); + return false; + } + final Intent launchIntent = new Intent().addCategory(Intent.CATEGORY_LAUNCHER) + .setPackage(pkgName); + + if (mPm.resolveActivity(launchIntent, 0) == null) { + // Not visible on launcher -> likely not a user visible app, skip + Log.d(TAG, "Not a user visible app, skipping " + pkgName); + return false; + } + return true; + } +} diff --git a/tests/robotests/src/com/android/settings/applications/RecentAppsPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/applications/RecentAppsPreferenceControllerTest.java new file mode 100644 index 0000000000..c66e229cb8 --- /dev/null +++ b/tests/robotests/src/com/android/settings/applications/RecentAppsPreferenceControllerTest.java @@ -0,0 +1,209 @@ +/* + * 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.settings.applications; + +import android.app.Application; +import android.app.usage.UsageStats; +import android.app.usage.UsageStatsManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.os.UserHandle; +import android.os.UserManager; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceCategory; +import android.support.v7.preference.PreferenceScreen; + +import com.android.settings.R; +import com.android.settings.SettingsRobolectricTestRunner; +import com.android.settings.TestConfig; +import com.android.settingslib.applications.ApplicationsState; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.util.ArrayList; +import java.util.List; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.anyLong; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(SettingsRobolectricTestRunner.class) +@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION) +public class RecentAppsPreferenceControllerTest { + + @Mock + private PreferenceScreen mScreen; + @Mock + private PreferenceCategory mCategory; + @Mock + private Preference mSeeAllPref; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private Context mMockContext; + @Mock + private UsageStatsManager mUsageStatsManager; + @Mock + private UserManager mUserManager; + @Mock + private ApplicationsState mAppState; + + private Context mContext; + private RecentAppsPreferenceController mController; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + when(mMockContext.getSystemService(Context.USAGE_STATS_SERVICE)) + .thenReturn(mUsageStatsManager); + when(mMockContext.getSystemService(Context.USER_SERVICE)) + .thenReturn(mUserManager); + + mContext = RuntimeEnvironment.application; + mController = new RecentAppsPreferenceController(mContext, mAppState, null); + when(mScreen.findPreference(anyString())).thenReturn(mCategory); + + when(mScreen.findPreference(RecentAppsPreferenceController.KEY_SEE_ALL)) + .thenReturn(mSeeAllPref); + when(mCategory.getContext()).thenReturn(mContext); + } + + @Test + public void isAlwaysAvailable() { + assertThat(mController.isAvailable()).isTrue(); + } + + @Test + public void doNotIndexCategory() { + final List nonIndexable = new ArrayList<>(); + + mController.updateNonIndexableKeys(nonIndexable); + + assertThat(nonIndexable).containsExactly(mController.getPreferenceKey()); + } + + @Test + public void onDisplayAndUpdateState_shouldRefreshUi() { + mController = spy( + new RecentAppsPreferenceController(mMockContext, (Application) null, null)); + + doNothing().when(mController).refreshUi(mContext); + + mController.displayPreference(mScreen); + mController.updateState(mCategory); + + verify(mController, times(2)).refreshUi(mContext); + } + + @Test + public void configOff_shouldNotDisplayRecentApps() { + mController = new RecentAppsPreferenceController(mMockContext, (Application) null, null); + when(mMockContext.getResources().getBoolean(R.bool.config_display_recent_apps)) + .thenReturn(false); + + assertThat(mController.shouldDisplayRecentApps()).isFalse(); + } + + @Test + public void configOn_shouldDisplayRecentAppsWhenHaveData() { + final List stats = new ArrayList<>(); + stats.add(mock(UsageStats.class)); + when(mMockContext.getResources().getBoolean(R.bool.config_display_recent_apps)) + .thenReturn(true); + when(mUsageStatsManager.queryUsageStats(anyInt(), anyLong(), anyLong())) + .thenReturn(stats); + + mController = new RecentAppsPreferenceController(mMockContext, mAppState, null); + + mController.reloadData(); + assertThat(mController.shouldDisplayRecentApps()).isTrue(); + } + + @Test + public void display_shouldNotShowRecents_showAppInfoPreference() { + mController = new RecentAppsPreferenceController(mMockContext, mAppState, null); + when(mMockContext.getResources().getBoolean(R.bool.config_display_recent_apps)) + .thenReturn(false); + + mController.displayPreference(mScreen); + + verify(mCategory, never()).addPreference(any(Preference.class)); + verify(mCategory).setTitle(null); + verify(mSeeAllPref).setTitle(R.string.applications_settings); + verify(mSeeAllPref).setIcon(null); + } + + @Test + public void display_showRecents() { + when(mMockContext.getResources().getBoolean(R.bool.config_display_recent_apps)) + .thenReturn(true); + final List stats = new ArrayList<>(); + final UsageStats stat1 = new UsageStats(); + final UsageStats stat2 = new UsageStats(); + final UsageStats stat3 = new UsageStats(); + stat1.mLastTimeUsed = System.currentTimeMillis(); + stat1.mPackageName = "pkg.class"; + stats.add(stat1); + + stat2.mLastTimeUsed = System.currentTimeMillis(); + stat2.mPackageName = "com.android.settings"; + stats.add(stat2); + + stat3.mLastTimeUsed = System.currentTimeMillis(); + stat3.mPackageName = "pkg.class2"; + stats.add(stat3); + + // stat1, stat2 are valid apps. stat3 is invalid. + when(mAppState.getEntry(stat1.mPackageName, UserHandle.myUserId())) + .thenReturn(mock(ApplicationsState.AppEntry.class)); + when(mAppState.getEntry(stat2.mPackageName, UserHandle.myUserId())) + .thenReturn(mock(ApplicationsState.AppEntry.class)); + when(mAppState.getEntry(stat3.mPackageName, UserHandle.myUserId())) + .thenReturn(null); + when(mMockContext.getPackageManager().resolveActivity(any(Intent.class), anyInt())) + .thenReturn(new ResolveInfo()); + when(mUsageStatsManager.queryUsageStats(anyInt(), anyLong(), anyLong())) + .thenReturn(stats); + + mController = new RecentAppsPreferenceController(mMockContext, mAppState, null); + mController.displayPreference(mScreen); + + verify(mCategory).setTitle(R.string.recent_app_category_title); + // Only add stat1. stat2 is skipped because of the package name, stat3 skipped because + // it's invalid app. + verify(mCategory, times(1)).addPreference(any(Preference.class)); + + verify(mSeeAllPref).setTitle(R.string.see_all_apps_title); + verify(mSeeAllPref).setIcon(R.drawable.ic_chevron_right_24dp); + } + +} -- 2.11.0