diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 2eaebb1173a..d6847fd0fe5 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -3205,6 +3205,15 @@ android:value="com.android.settings.applications.AppAndNotificationDashboardFragment"/> + + + + Use battery saver + + See all apps diff --git a/res/xml/apps.xml b/res/xml/apps.xml index 3ec4a290f7a..54af67bf986 100644 --- a/res/xml/apps.xml +++ b/res/xml/apps.xml @@ -18,80 +18,52 @@ - + android:key="apps_screen" + android:title="@string/apps_dashboard_title"> - + settings:searchable="false"> + + + + + + - - - - - - + android:key="general_category" + android:title="@string/category_name_general" + android:order="-997" + android:visibility="gone" + settings:searchable="false"/> - - - - - - - - - + diff --git a/res/xml/top_level_settings_grouped.xml b/res/xml/top_level_settings_grouped.xml index ad634ed62f3..d6564b71f93 100644 --- a/res/xml/top_level_settings_grouped.xml +++ b/res/xml/top_level_settings_grouped.xml @@ -41,9 +41,9 @@ android:order="-120" settings:allowDividerAbove="false"> @@ -53,8 +53,6 @@ android:key="top_level_notification" android:order="-110" android:title="@string/configure_notification_settings"/> - - buildPreferenceControllers(Context context) { + final List controllers = new ArrayList<>(); + controllers.add(new AppsPreferenceController(context)); + return controllers; + } + + @Override + public int getMetricsCategory() { + return SettingsEnums.SETTINGS_APP_NOTIF_CATEGORY; + } + + @Override + protected String getLogTag() { + return TAG; + } + + @Override + public int getHelpResource() { + return R.string.help_url_apps_and_notifications; + } + + @Override + protected int getPreferenceScreenResId() { + return R.xml.apps; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + mAppsPreferenceController = use(AppsPreferenceController.class); + mAppsPreferenceController.setFragment(this /* fragment */); + } + + @Override + protected List createPreferenceControllers(Context context) { + return buildPreferenceControllers(context); + } + + @Override + public String getCategoryKey() { + // TODO(b/174964405): Remove this function when the silky flag was deprecated. + // To include injection tiles, map this app fragment to the app category in the short term. + // When we deprecate the silky flag, we have to: + // 1. Remove this method. + // 2. Update the mapping in DashboardFragmentRegistry.PARENT_TO_CATEGORY_KEY_MAP. + return CategoryKey.CATEGORY_APPS; + } + + public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER = + new BaseSearchIndexProvider(R.xml.apps); +} diff --git a/src/com/android/settings/applications/AppsPreferenceController.java b/src/com/android/settings/applications/AppsPreferenceController.java new file mode 100644 index 00000000000..08fd9c5385b --- /dev/null +++ b/src/com/android/settings/applications/AppsPreferenceController.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2021 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.content.Context; +import android.icu.text.RelativeDateTimeFormatter; +import android.os.UserHandle; +import android.text.TextUtils; +import android.util.ArrayMap; + +import androidx.annotation.VisibleForTesting; +import androidx.fragment.app.Fragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceScreen; + +import com.android.settings.R; +import com.android.settings.applications.appinfo.AppInfoDashboardFragment; +import com.android.settings.core.BasePreferenceController; +import com.android.settingslib.Utils; +import com.android.settingslib.applications.ApplicationsState; +import com.android.settingslib.utils.StringUtil; +import com.android.settingslib.widget.AppPreference; + +import java.util.List; +import java.util.Map; + +/** + * This controller displays up to four recently used apps. + * If there is no recently used app, we only show up an "App Info" preference. + */ +public class AppsPreferenceController extends BasePreferenceController { + + public static final int SHOW_RECENT_APP_COUNT = 4; + + @VisibleForTesting + static final String KEY_RECENT_APPS_CATEGORY = "recent_apps_category"; + @VisibleForTesting + static final String KEY_GENERAL_CATEGORY = "general_category"; + @VisibleForTesting + static final String KEY_ALL_APP_INFO = "all_app_info"; + @VisibleForTesting + static final String KEY_SEE_ALL = "see_all_apps"; + + private final ApplicationsState mApplicationsState; + private final int mUserId; + + @VisibleForTesting + List mRecentApps; + @VisibleForTesting + PreferenceCategory mRecentAppsCategory; + @VisibleForTesting + PreferenceCategory mGeneralCategory; + @VisibleForTesting + Preference mAllAppsInfoPref; + @VisibleForTesting + Preference mSeeAllPref; + + private Fragment mHost; + + public AppsPreferenceController(Context context) { + super(context, KEY_RECENT_APPS_CATEGORY); + mApplicationsState = ApplicationsState.getInstance( + (Application) mContext.getApplicationContext()); + mUserId = UserHandle.myUserId(); + } + + public void setFragment(Fragment fragment) { + mHost = fragment; + } + + @Override + public int getAvailabilityStatus() { + return AVAILABLE_UNSEARCHABLE; + } + + @Override + public void displayPreference(PreferenceScreen screen) { + super.displayPreference(screen); + initPreferences(screen); + refreshUi(); + } + + @Override + public void updateState(Preference preference) { + super.updateState(preference); + refreshUi(); + } + + @VisibleForTesting + void refreshUi() { + loadAllAppsCount(); + mRecentApps = loadRecentApps(); + if (!mRecentApps.isEmpty()) { + displayRecentApps(); + mRecentAppsCategory.setVisible(true); + mGeneralCategory.setVisible(true); + mSeeAllPref.setVisible(true); + } else { + mAllAppsInfoPref.setVisible(true); + } + } + + @VisibleForTesting + void loadAllAppsCount() { + // Show total number of installed apps as See all's summary. + new InstalledAppCounter(mContext, InstalledAppCounter.IGNORE_INSTALL_REASON, + mContext.getPackageManager()) { + @Override + protected void onCountComplete(int num) { + if (!mRecentApps.isEmpty()) { + mSeeAllPref.setTitle( + mContext.getResources().getQuantityString(R.plurals.see_all_apps_title, + num, num)); + } else { + mAllAppsInfoPref.setSummary(mContext.getString(R.string.apps_summary, num)); + } + } + }.execute(); + } + + @VisibleForTesting + List loadRecentApps() { + final RecentAppStatsMixin recentAppStatsMixin = new RecentAppStatsMixin(mContext, + SHOW_RECENT_APP_COUNT); + recentAppStatsMixin.loadDisplayableRecentApps(SHOW_RECENT_APP_COUNT); + return recentAppStatsMixin.mRecentApps; + } + + private void initPreferences(PreferenceScreen screen) { + mRecentAppsCategory = screen.findPreference(KEY_RECENT_APPS_CATEGORY); + mGeneralCategory = screen.findPreference(KEY_GENERAL_CATEGORY); + mAllAppsInfoPref = screen.findPreference(KEY_ALL_APP_INFO); + mSeeAllPref = screen.findPreference(KEY_SEE_ALL); + mRecentAppsCategory.setVisible(false); + mGeneralCategory.setVisible(false); + mAllAppsInfoPref.setVisible(false); + mSeeAllPref.setVisible(false); + } + + private void displayRecentApps() { + if (mRecentAppsCategory != null) { + final Map existedAppPreferences = new ArrayMap<>(); + final int prefCount = mRecentAppsCategory.getPreferenceCount(); + for (int i = 0; i < prefCount; i++) { + final Preference pref = mRecentAppsCategory.getPreference(i); + final String key = pref.getKey(); + if (!TextUtils.equals(key, KEY_SEE_ALL)) { + existedAppPreferences.put(key, pref); + } + } + + int showAppsCount = 0; + for (UsageStats stat : mRecentApps) { + final String pkgName = stat.getPackageName(); + final ApplicationsState.AppEntry appEntry = + mApplicationsState.getEntry(pkgName, mUserId); + if (appEntry == null) { + continue; + } + + boolean rebindPref = true; + Preference pref = existedAppPreferences.remove(pkgName); + if (pref == null) { + pref = new AppPreference(mContext); + rebindPref = false; + } + + pref.setKey(pkgName); + pref.setTitle(appEntry.label); + pref.setIcon(Utils.getBadgedIcon(mContext, appEntry.info)); + pref.setSummary(StringUtil.formatRelativeTime(mContext, + System.currentTimeMillis() - stat.getLastTimeUsed(), false, + RelativeDateTimeFormatter.Style.SHORT)); + pref.setOrder(showAppsCount++); + pref.setOnPreferenceClickListener(preference -> { + AppInfoBase.startAppInfoFragment(AppInfoDashboardFragment.class, + R.string.application_info_label, pkgName, appEntry.info.uid, + mHost, 1001 /*RequestCode*/, getMetricsCategory()); + return true; + }); + + if (!rebindPref) { + mRecentAppsCategory.addPreference(pref); + } + } + + // Remove unused preferences from pref category. + for (Preference unusedPref : existedAppPreferences.values()) { + mRecentAppsCategory.removePreference(unusedPref); + } + } + } +} diff --git a/src/com/android/settings/core/gateway/SettingsGateway.java b/src/com/android/settings/core/gateway/SettingsGateway.java index 2c21771564d..0e0d3eba09d 100644 --- a/src/com/android/settings/core/gateway/SettingsGateway.java +++ b/src/com/android/settings/core/gateway/SettingsGateway.java @@ -35,6 +35,7 @@ import com.android.settings.accounts.AccountSyncSettings; import com.android.settings.accounts.ChooseAccountFragment; import com.android.settings.accounts.ManagedProfileSettings; import com.android.settings.applications.AppAndNotificationDashboardFragment; +import com.android.settings.applications.AppDashboardFragment; import com.android.settings.applications.ProcessStatsSummary; import com.android.settings.applications.ProcessStatsUi; import com.android.settings.applications.UsageAccessDetails; @@ -289,6 +290,7 @@ public class SettingsGateway { ConnectedDeviceDashboardFragment.class.getName(), UsbDetailsFragment.class.getName(), AppAndNotificationDashboardFragment.class.getName(), + AppDashboardFragment.class.getName(), WifiCallingDisclaimerFragment.class.getName(), AccountDashboardFragment.class.getName(), EnterprisePrivacySettings.class.getName(), @@ -317,6 +319,7 @@ public class SettingsGateway { Settings.NetworkDashboardActivity.class.getName(), Settings.ConnectedDeviceDashboardActivity.class.getName(), Settings.AppAndNotificationDashboardActivity.class.getName(), + Settings.AppDashboardActivity.class.getName(), Settings.DisplaySettingsActivity.class.getName(), Settings.SoundSettingsActivity.class.getName(), Settings.StorageDashboardActivity.class.getName(), diff --git a/tests/robotests/src/com/android/settings/applications/AppDashboardFragmentTest.java b/tests/robotests/src/com/android/settings/applications/AppDashboardFragmentTest.java new file mode 100644 index 00000000000..0eca43c66f6 --- /dev/null +++ b/tests/robotests/src/com/android/settings/applications/AppDashboardFragmentTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2021 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 static com.google.common.truth.Truth.assertThat; + +import android.content.Context; + +import com.android.settings.testutils.XmlTestUtils; +import com.android.settings.testutils.shadow.ShadowUserManager; +import com.android.settingslib.core.AbstractPreferenceController; +import com.android.settingslib.drawer.CategoryKey; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class AppDashboardFragmentTest { + + private Context mContext; + private AppDashboardFragment mFragment; + + @Before + public void setUp() { + mContext = RuntimeEnvironment.application; + mFragment = new AppDashboardFragment(); + } + + @Test + public void testCategory_isApps() { + assertThat(mFragment.getCategoryKey()).isEqualTo(CategoryKey.CATEGORY_APPS); + } + + @Test + @Config(shadows = ShadowUserManager.class) + public void testPreferenceControllers_existInPreferenceScreen() { + final List preferenceScreenKeys = XmlTestUtils.getKeysFromPreferenceXml(mContext, + mFragment.getPreferenceScreenResId()); + final List preferenceKeys = new ArrayList<>(); + + for (AbstractPreferenceController controller : mFragment.createPreferenceControllers( + mContext)) { + preferenceKeys.add(controller.getPreferenceKey()); + } + + assertThat(preferenceScreenKeys).containsAtLeastElementsIn(preferenceKeys); + } +} diff --git a/tests/robotests/src/com/android/settings/applications/AppsPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/applications/AppsPreferenceControllerTest.java new file mode 100644 index 00000000000..75da4d8d875 --- /dev/null +++ b/tests/robotests/src/com/android/settings/applications/AppsPreferenceControllerTest.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2021 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 static com.android.settings.applications.AppsPreferenceController.KEY_ALL_APP_INFO; +import static com.android.settings.applications.AppsPreferenceController.KEY_GENERAL_CATEGORY; +import static com.android.settings.applications.AppsPreferenceController.KEY_RECENT_APPS_CATEGORY; +import static com.android.settings.applications.AppsPreferenceController.KEY_SEE_ALL; +import static com.android.settings.core.BasePreferenceController.AVAILABLE_UNSEARCHABLE; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.usage.UsageStats; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.os.UserHandle; + +import androidx.fragment.app.Fragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceScreen; + +import com.android.settingslib.applications.ApplicationsState; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.util.ReflectionHelpers; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class AppsPreferenceControllerTest { + + @Mock + private ApplicationsState mAppState; + @Mock + private ApplicationsState.AppEntry mAppEntry; + @Mock + private ApplicationInfo mApplicationInfo; + @Mock + private Fragment mFragment; + @Mock + private PreferenceScreen mScreen; + + private AppsPreferenceController mController; + private List mUsageStats; + private PreferenceCategory mRecentAppsCategory; + private PreferenceCategory mGeneralCategory; + private Preference mSeeAllPref; + private Preference mAllAppsInfoPref; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + final Context context = RuntimeEnvironment.application; + ReflectionHelpers.setStaticField(ApplicationsState.class, "sInstance", mAppState); + + mRecentAppsCategory = spy(new PreferenceCategory(context)); + mGeneralCategory = new PreferenceCategory(context); + mSeeAllPref = new Preference(context); + mAllAppsInfoPref = new Preference(context); + when(mScreen.findPreference(KEY_RECENT_APPS_CATEGORY)).thenReturn(mRecentAppsCategory); + when(mScreen.findPreference(KEY_GENERAL_CATEGORY)).thenReturn(mGeneralCategory); + when(mScreen.findPreference(KEY_SEE_ALL)).thenReturn(mSeeAllPref); + when(mScreen.findPreference(KEY_ALL_APP_INFO)).thenReturn(mAllAppsInfoPref); + + mController = spy(new AppsPreferenceController(context)); + mController.setFragment(mFragment); + mController.mRecentAppsCategory = mRecentAppsCategory; + mController.mGeneralCategory = mGeneralCategory; + mController.mSeeAllPref = mSeeAllPref; + mController.mAllAppsInfoPref = mAllAppsInfoPref; + } + + @Test + public void getAvailabilityStatus_shouldReturnAVAILABLE_UNSEARCHABLE() { + assertThat(mController.getAvailabilityStatus()).isEqualTo(AVAILABLE_UNSEARCHABLE); + } + + @Test + public void displayPreference_noRecentApps_showAllAppsInfo() { + doNothing().when(mController).loadAllAppsCount(); + + mController.displayPreference(mScreen); + + assertThat(mAllAppsInfoPref.isVisible()).isTrue(); + assertThat(mRecentAppsCategory.isVisible()).isFalse(); + assertThat(mGeneralCategory.isVisible()).isFalse(); + assertThat(mSeeAllPref.isVisible()).isFalse(); + } + + @Test + public void displayPreference_hasRecentApps_showRecentApps() { + doNothing().when(mController).loadAllAppsCount(); + doReturn(true).when(mRecentAppsCategory).addPreference(any()); + initRecentApps(); + doReturn(mUsageStats).when(mController).loadRecentApps(); + + mController.displayPreference(mScreen); + + assertThat(mAllAppsInfoPref.isVisible()).isFalse(); + assertThat(mRecentAppsCategory.isVisible()).isTrue(); + assertThat(mGeneralCategory.isVisible()).isTrue(); + assertThat(mSeeAllPref.isVisible()).isTrue(); + } + + @Test + public void updateState_shouldRefreshUi() { + doNothing().when(mController).loadAllAppsCount(); + + mController.updateState(mRecentAppsCategory); + + verify(mController).refreshUi(); + } + + private void initRecentApps() { + mUsageStats = 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"; + mUsageStats.add(stat1); + + stat2.mLastTimeUsed = System.currentTimeMillis(); + stat2.mPackageName = "pkg.class2"; + mUsageStats.add(stat2); + + stat3.mLastTimeUsed = System.currentTimeMillis(); + stat3.mPackageName = "pkg.class3"; + mUsageStats.add(stat3); + when(mAppState.getEntry(stat1.mPackageName, UserHandle.myUserId())) + .thenReturn(mAppEntry); + when(mAppState.getEntry(stat2.mPackageName, UserHandle.myUserId())) + .thenReturn(mAppEntry); + when(mAppState.getEntry(stat3.mPackageName, UserHandle.myUserId())) + .thenReturn(mAppEntry); + mAppEntry.info = mApplicationInfo; + } +}