diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index 7abdf278a3b..c1bf7e5d19c 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 15a901427cf..d53afc965e8 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 81e3b0fc366..b3f1e402075 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 639735dcb59..9ddff85aa31 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 8758b1453bb..8c7aed7aefa 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 00000000000..4ff2dbf5695
--- /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 00000000000..c66e229cb86
--- /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);
+ }
+
+}