diff --git a/res/values/strings.xml b/res/values/strings.xml index 164f6d5de29..bc95ae9e880 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -6965,6 +6965,9 @@ Notifications + + Recently sent + Advanced diff --git a/res/xml/configure_notification_settings.xml b/res/xml/configure_notification_settings.xml index 21904e671a9..520ebaa5fa8 100644 --- a/res/xml/configure_notification_settings.xml +++ b/res/xml/configure_notification_settings.xml @@ -19,8 +19,28 @@ android:key="configure_notification_settings"> + android:key="recent_notifications_category" + android:title="@string/recent_notifications" + android:order="-200"> + + + + + + + + + + + getPreferenceControllers(Context context) { - return buildPreferenceControllers(context, getLifecycle()); + final Activity activity = getActivity(); + final Application app; + if (activity != null) { + app = activity.getApplication(); + } else { + app = null; + } + return buildPreferenceControllers(context, getLifecycle(), app, this); } private static List buildPreferenceControllers(Context context, - Lifecycle lifecycle) { + Lifecycle lifecycle, Application app, Fragment host) { final List controllers = new ArrayList<>(); final BadgingNotificationPreferenceController badgeController = new BadgingNotificationPreferenceController(context); @@ -96,6 +105,8 @@ public class ConfigureNotificationSettings extends DashboardFragment { lifecycle.addObserver(pulseController); lifecycle.addObserver(lockScreenNotificationController); } + controllers.add(new RecentNotifyingAppsPreferenceController( + context, new NotificationBackend(), app, host)); controllers.add(new SwipeToNotificationPreferenceController(context, lifecycle, KEY_SWIPE_DOWN)); controllers.add(badgeController); @@ -167,7 +178,7 @@ public class ConfigureNotificationSettings extends DashboardFragment { @Override public List getPreferenceControllers( Context context) { - return buildPreferenceControllers(context, null); + return buildPreferenceControllers(context, null, null, null); } @Override diff --git a/src/com/android/settings/notification/NotificationBackend.java b/src/com/android/settings/notification/NotificationBackend.java index 4de528e0a03..e047efa154b 100644 --- a/src/com/android/settings/notification/NotificationBackend.java +++ b/src/com/android/settings/notification/NotificationBackend.java @@ -27,12 +27,16 @@ import android.content.pm.ParceledListSlice; import android.graphics.drawable.Drawable; import android.os.ServiceManager; import android.os.UserHandle; +import android.service.notification.NotifyingApp; import android.util.IconDrawableFactory; import android.util.Log; import com.android.internal.annotations.VisibleForTesting; import com.android.settingslib.Utils; +import java.util.ArrayList; +import java.util.List; + public class NotificationBackend { private static final String TAG = "NotificationBackend"; @@ -185,7 +189,6 @@ public class NotificationBackend { } } - public int getDeletedChannelCount(String pkg, int uid) { try { return sINM.getDeletedChannelCount(pkg, uid); @@ -204,6 +207,15 @@ public class NotificationBackend { } } + public List getRecentApps() { + try { + return sINM.getRecentNotifyingAppsForUser(UserHandle.myUserId()).getList(); + } catch (Exception e) { + Log.w(TAG, "Error calling NoMan", e); + return new ArrayList<>(); + } + } + static class Row { public String section; } diff --git a/src/com/android/settings/notification/RecentNotifyingAppsPreferenceController.java b/src/com/android/settings/notification/RecentNotifyingAppsPreferenceController.java new file mode 100644 index 00000000000..ef34a9b65e6 --- /dev/null +++ b/src/com/android/settings/notification/RecentNotifyingAppsPreferenceController.java @@ -0,0 +1,293 @@ +/* + * 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.settings.notification; + +import android.app.Application; +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.UserHandle; +import android.service.notification.NotifyingApp; +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.util.ArrayMap; +import android.util.ArraySet; +import android.util.IconDrawableFactory; +import android.util.Log; + +import com.android.internal.logging.nano.MetricsProto; +import com.android.settings.R; +import com.android.settings.Utils; +import com.android.settings.applications.AppInfoBase; +import com.android.settings.applications.InstalledAppCounter; +import com.android.settings.core.PreferenceControllerMixin; +import com.android.settings.widget.AppPreference; +import com.android.settingslib.applications.AppUtils; +import com.android.settingslib.applications.ApplicationsState; +import com.android.settingslib.core.AbstractPreferenceController; +import com.android.settingslib.wrapper.PackageManagerWrapper; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 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 "Notifications". + */ +public class RecentNotifyingAppsPreferenceController extends AbstractPreferenceController + implements PreferenceControllerMixin { + + private static final String TAG = "RecentNotisCtrl"; + private static final String KEY_PREF_CATEGORY = "recent_notifications_category"; + @VisibleForTesting + static final String KEY_DIVIDER = "all_notifications_divider"; + @VisibleForTesting + static final String KEY_SEE_ALL = "all_notifications"; + 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 NotificationBackend mNotificationBackend; + private final int mUserId; + private final IconDrawableFactory mIconDrawableFactory; + + private List mApps; + private final ApplicationsState mApplicationsState; + + private PreferenceCategory mCategory; + private Preference mSeeAllPref; + private Preference mDivider; + private boolean mHasRecentApps; + + 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 RecentNotifyingAppsPreferenceController(Context context, NotificationBackend backend, + Application app, Fragment host) { + this(context, backend, app == null ? null : ApplicationsState.getInstance(app), host); + } + + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + RecentNotifyingAppsPreferenceController(Context context, NotificationBackend backend, + ApplicationsState appState, Fragment host) { + super(context); + mIconDrawableFactory = IconDrawableFactory.newInstance(context); + mUserId = UserHandle.myUserId(); + mPm = context.getPackageManager(); + mHost = host; + mApplicationsState = appState; + mNotificationBackend = backend; + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public String getPreferenceKey() { + return KEY_PREF_CATEGORY; + } + + @Override + public void updateNonIndexableKeys(List keys) { + PreferenceControllerMixin.super.updateNonIndexableKeys(keys); + // Don't index category name into search. It's not actionable. + keys.add(KEY_PREF_CATEGORY); + keys.add(KEY_DIVIDER); + } + + @Override + public void displayPreference(PreferenceScreen screen) { + mCategory = (PreferenceCategory) screen.findPreference(getPreferenceKey()); + mSeeAllPref = screen.findPreference(KEY_SEE_ALL); + mDivider = screen.findPreference(KEY_DIVIDER); + super.displayPreference(screen); + refreshUi(mCategory.getContext()); + } + + @Override + public void updateState(Preference preference) { + super.updateState(preference); + refreshUi(mCategory.getContext()); + // Show total number of installed apps as See all's summary. + new InstalledAppCounter(mContext, InstalledAppCounter.IGNORE_INSTALL_REASON, + new PackageManagerWrapper(mContext.getPackageManager())) { + @Override + protected void onCountComplete(int num) { + if (mHasRecentApps) { + mSeeAllPref.setTitle(mContext.getString(R.string.see_all_apps_title, num)); + } else { + mSeeAllPref.setSummary(mContext.getString(R.string.apps_summary, num)); + } + } + }.execute(); + + } + + @VisibleForTesting + void refreshUi(Context prefContext) { + reloadData(); + final List recentApps = getDisplayableRecentAppList(); + if (recentApps != null && !recentApps.isEmpty()) { + mHasRecentApps = true; + displayRecentApps(prefContext, recentApps); + } else { + mHasRecentApps = false; + displayOnlyAllAppsLink(); + } + } + + @VisibleForTesting + void reloadData() { + mApps = mNotificationBackend.getRecentApps(); + } + + private void displayOnlyAllAppsLink() { + mCategory.setTitle(null); + mDivider.setVisible(false); + mSeeAllPref.setTitle(R.string.notifications_title); + 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, List recentApps) { + mCategory.setTitle(R.string.recent_notifications); + mDivider.setVisible(true); + mSeeAllPref.setSummary(null); + mSeeAllPref.setIcon(R.drawable.ic_chevron_right_24dp); + + // 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 NotifyingApp app = recentApps.get(i); + // Bind recent apps to existing prefs if possible, or create a new pref. + final String pkgName = app.getPackage(); + final ApplicationsState.AppEntry appEntry = + mApplicationsState.getEntry(app.getPackage(), mUserId); + if (appEntry == null) { + continue; + } + + boolean rebindPref = true; + Preference pref = appPreferences.remove(pkgName); + if (pref == null) { + pref = new AppPreference(prefContext); + rebindPref = false; + } + pref.setKey(pkgName); + pref.setTitle(appEntry.label); + pref.setIcon(mIconDrawableFactory.getBadgedIcon(appEntry.info)); + pref.setSummary(Utils.formatRelativeTime(mContext, + System.currentTimeMillis() - app.getLastNotified(), false)); + pref.setOrder(i); + pref.setOnPreferenceClickListener(preference -> { + AppInfoBase.startAppInfoFragment(AppNotificationSettings.class, + R.string.notifications_title, pkgName, appEntry.info.uid, mHost, + 1001 /*RequestCode */, + MetricsProto.MetricsEvent.MANAGE_APPLICATIONS_NOTIFICATIONS); + 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() { + Collections.sort(mApps); + List displayableApps = new ArrayList<>(SHOW_RECENT_APP_COUNT); + int count = 0; + for (NotifyingApp app : mApps) { + final ApplicationsState.AppEntry appEntry = mApplicationsState.getEntry( + app.getPackage(), mUserId); + if (appEntry == null) { + continue; + } + if (!shouldIncludePkgInRecents(app.getPackage())) { + continue; + } + displayableApps.add(app); + count++; + if (count >= SHOW_RECENT_APP_COUNT) { + break; + } + } + return displayableApps; + } + + + /** + * Whether or not the app should be included in recent list. + */ + private boolean shouldIncludePkgInRecents(String pkgName) { + 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 if non-instant. + final ApplicationsState.AppEntry appEntry = + mApplicationsState.getEntry(pkgName, mUserId); + if (!AppUtils.isInstant(appEntry.info)) { + Log.d(TAG, "Not a user visible or instant app, skipping " + pkgName); + return false; + } + } + return true; + } +} diff --git a/tests/robotests/src/android/service/notification/NotifyingApp.java b/tests/robotests/src/android/service/notification/NotifyingApp.java new file mode 100644 index 00000000000..f36069b20c2 --- /dev/null +++ b/tests/robotests/src/android/service/notification/NotifyingApp.java @@ -0,0 +1,112 @@ +/* + * 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 android.service.notification; + +import android.annotation.NonNull; +import android.os.Parcel; +import android.os.Parcelable; + +import java.util.Objects; + +/** + * Stub implementation of framework's NotifyingApp for Robolectric tests. Otherwise Robolectric + * throws ClassNotFoundError. + * + * TODO: Remove this class when Robolectric supports P + */ +public final class NotifyingApp implements Comparable { + + private int mUid; + private String mPkg; + private long mLastNotified; + + public NotifyingApp() {} + + public int getUid() { + return mUid; + } + + /** + * Sets the uid of the package that sent the notification. Returns self. + */ + public NotifyingApp setUid(int mUid) { + this.mUid = mUid; + return this; + } + + public String getPackage() { + return mPkg; + } + + /** + * Sets the package that sent the notification. Returns self. + */ + public NotifyingApp setPackage(@NonNull String mPkg) { + this.mPkg = mPkg; + return this; + } + + public long getLastNotified() { + return mLastNotified; + } + + /** + * Sets the time the notification was originally sent. Returns self. + */ + public NotifyingApp setLastNotified(long mLastNotified) { + this.mLastNotified = mLastNotified; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NotifyingApp that = (NotifyingApp) o; + return getUid() == that.getUid() + && getLastNotified() == that.getLastNotified() + && Objects.equals(mPkg, that.mPkg); + } + + @Override + public int hashCode() { + return Objects.hash(getUid(), mPkg, getLastNotified()); + } + + /** + * Sorts notifying apps from newest last notified date to oldest. + */ + @Override + public int compareTo(NotifyingApp o) { + if (getLastNotified() == o.getLastNotified()) { + if (getUid() == o.getUid()) { + return getPackage().compareTo(o.getPackage()); + } + return Integer.compare(getUid(), o.getUid()); + } + + return -Long.compare(getLastNotified(), o.getLastNotified()); + } + + @Override + public String toString() { + return "NotifyingApp{" + + "mUid=" + mUid + + ", mPkg='" + mPkg + '\'' + + ", mLastNotified=" + mLastNotified + + '}'; + } +} diff --git a/tests/robotests/src/com/android/settings/notification/RecentNotifyingAppsPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/RecentNotifyingAppsPreferenceControllerTest.java new file mode 100644 index 00000000000..a25bb002502 --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/RecentNotifyingAppsPreferenceControllerTest.java @@ -0,0 +1,301 @@ +/* + * 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.settings.notification; + +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.anyString; +import static org.mockito.Matchers.argThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +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; + +import android.app.Application; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.os.UserHandle; +import android.os.UserManager; +import android.service.notification.NotifyingApp; +import android.support.v7.preference.Preference; +import android.support.v7.preference.PreferenceCategory; +import android.support.v7.preference.PreferenceScreen; +import android.text.TextUtils; + +import com.android.settings.R; +import com.android.settings.TestConfig; +import com.android.settings.testutils.SettingsRobolectricTestRunner; +import com.android.settingslib.applications.AppUtils; +import com.android.settingslib.applications.ApplicationsState; +import com.android.settingslib.applications.instantapps.InstantAppDataProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatcher; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.util.ReflectionHelpers; + +import java.util.ArrayList; +import java.util.List; + +@RunWith(SettingsRobolectricTestRunner.class) +@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION) +public class RecentNotifyingAppsPreferenceControllerTest { + + @Mock + private PreferenceScreen mScreen; + @Mock + private PreferenceCategory mCategory; + @Mock + private Preference mSeeAllPref; + @Mock + private PreferenceCategory mDivider; + @Mock + private UserManager mUserManager; + @Mock + private ApplicationsState mAppState; + @Mock + private PackageManager mPackageManager; + @Mock + private ApplicationsState.AppEntry mAppEntry; + @Mock + private ApplicationInfo mApplicationInfo; + @Mock + private NotificationBackend mBackend; + + private Context mContext; + private RecentNotifyingAppsPreferenceController mController; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = spy(RuntimeEnvironment.application); + doReturn(mUserManager).when(mContext).getSystemService(Context.USER_SERVICE); + doReturn(mPackageManager).when(mContext).getPackageManager(); + + mController = new RecentNotifyingAppsPreferenceController( + mContext, mBackend, mAppState, null); + when(mScreen.findPreference(anyString())).thenReturn(mCategory); + + when(mScreen.findPreference(RecentNotifyingAppsPreferenceController.KEY_SEE_ALL)) + .thenReturn(mSeeAllPref); + when(mScreen.findPreference(RecentNotifyingAppsPreferenceController.KEY_DIVIDER)) + .thenReturn(mDivider); + 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).containsAllOf(mController.getPreferenceKey(), + RecentNotifyingAppsPreferenceController.KEY_DIVIDER); + } + + @Test + public void onDisplayAndUpdateState_shouldRefreshUi() { + mController = spy(new RecentNotifyingAppsPreferenceController( + mContext, null, (ApplicationsState) null, null)); + + doNothing().when(mController).refreshUi(mContext); + + mController.displayPreference(mScreen); + mController.updateState(mCategory); + + verify(mController, times(2)).refreshUi(mContext); + } + + @Test + @Config(qualifiers = "mcc999") + public void display_shouldNotShowRecents_showAppInfoPreference() { + mController.displayPreference(mScreen); + + verify(mCategory, never()).addPreference(any(Preference.class)); + verify(mCategory).setTitle(null); + verify(mSeeAllPref).setTitle(R.string.notifications_title); + verify(mSeeAllPref).setIcon(null); + verify(mDivider).setVisible(false); + } + + @Test + public void display_showRecents() { + final List apps = new ArrayList<>(); + final NotifyingApp app1 = new NotifyingApp() + .setPackage("pkg.class") + .setLastNotified(System.currentTimeMillis()); + final NotifyingApp app2 = new NotifyingApp() + .setLastNotified(System.currentTimeMillis()) + .setPackage("com.android.settings"); + final NotifyingApp app3 = new NotifyingApp() + .setLastNotified(System.currentTimeMillis() - 1000) + .setPackage("pkg.class2"); + + apps.add(app1); + apps.add(app2); + apps.add(app3); + + // app1, app2 are valid apps. app3 is invalid. + when(mAppState.getEntry(app1.getPackage(), UserHandle.myUserId())) + .thenReturn(mAppEntry); + when(mAppState.getEntry(app2.getPackage(), UserHandle.myUserId())) + .thenReturn(mAppEntry); + when(mAppState.getEntry(app3.getPackage(), UserHandle.myUserId())) + .thenReturn(null); + when(mPackageManager.resolveActivity(any(Intent.class), anyInt())).thenReturn( + new ResolveInfo()); + when(mBackend.getRecentApps()).thenReturn(apps); + mAppEntry.info = mApplicationInfo; + + mController.displayPreference(mScreen); + + verify(mCategory).setTitle(R.string.recent_notifications); + // Only add app1. app2 is skipped because of the package name, app3 skipped because + // it's invalid app. + verify(mCategory, times(1)).addPreference(any(Preference.class)); + + verify(mSeeAllPref).setSummary(null); + verify(mSeeAllPref).setIcon(R.drawable.ic_chevron_right_24dp); + verify(mDivider).setVisible(true); + } + + @Test + public void display_showRecentsWithInstantApp() { + // Regular app. + final List apps = new ArrayList<>(); + final NotifyingApp app1 = new NotifyingApp(). + setLastNotified(System.currentTimeMillis()) + .setPackage("com.foo.bar"); + apps.add(app1); + + // Instant app. + final NotifyingApp app2 = new NotifyingApp() + .setLastNotified(System.currentTimeMillis() + 200) + .setPackage("com.foo.barinstant"); + apps.add(app2); + + ApplicationsState.AppEntry app1Entry = mock(ApplicationsState.AppEntry.class); + ApplicationsState.AppEntry app2Entry = mock(ApplicationsState.AppEntry.class); + app1Entry.info = mApplicationInfo; + app2Entry.info = mApplicationInfo; + + when(mAppState.getEntry(app1.getPackage(), UserHandle.myUserId())).thenReturn(app1Entry); + when(mAppState.getEntry(app2.getPackage(), UserHandle.myUserId())).thenReturn(app2Entry); + + // Only the regular app app1 should have its intent resolve. + when(mPackageManager.resolveActivity(argThat(intentMatcher(app1.getPackage())), + anyInt())).thenReturn(new ResolveInfo()); + + when(mBackend.getRecentApps()).thenReturn(apps); + + // Make sure app2 is considered an instant app. + ReflectionHelpers.setStaticField(AppUtils.class, "sInstantAppDataProvider", + (InstantAppDataProvider) (ApplicationInfo info) -> { + if (info == app2Entry.info) { + return true; + } else { + return false; + } + }); + + mController.displayPreference(mScreen); + + ArgumentCaptor prefCaptor = ArgumentCaptor.forClass(Preference.class); + verify(mCategory, times(2)).addPreference(prefCaptor.capture()); + List prefs = prefCaptor.getAllValues(); + assertThat(prefs.get(1).getKey()).isEqualTo(app1.getPackage()); + assertThat(prefs.get(0).getKey()).isEqualTo(app2.getPackage()); + } + + @Test + public void display_hasRecentButNoneDisplayable_showAppInfo() { + final List apps = new ArrayList<>(); + final NotifyingApp app1 = new NotifyingApp() + .setPackage("com.android.phone") + .setLastNotified(System.currentTimeMillis()); + final NotifyingApp app2 = new NotifyingApp() + .setPackage("com.android.settings") + .setLastNotified(System.currentTimeMillis()); + apps.add(app1); + apps.add(app2); + + // app1, app2 are not displayable + when(mAppState.getEntry(app1.getPackage(), UserHandle.myUserId())) + .thenReturn(mock(ApplicationsState.AppEntry.class)); + when(mAppState.getEntry(app2.getPackage(), UserHandle.myUserId())) + .thenReturn(mock(ApplicationsState.AppEntry.class)); + when(mPackageManager.resolveActivity(any(Intent.class), anyInt())).thenReturn( + new ResolveInfo()); + when(mBackend.getRecentApps()).thenReturn(apps); + + mController.displayPreference(mScreen); + + verify(mCategory, never()).addPreference(any(Preference.class)); + verify(mCategory).setTitle(null); + verify(mSeeAllPref).setTitle(R.string.notifications_title); + verify(mSeeAllPref).setIcon(null); + } + + @Test + public void display_showRecents_formatSummary() { + final List apps = new ArrayList<>(); + final NotifyingApp app1 = new NotifyingApp() + .setLastNotified(System.currentTimeMillis()) + .setPackage("pkg.class"); + apps.add(app1); + + when(mAppState.getEntry(app1.getPackage(), UserHandle.myUserId())) + .thenReturn(mAppEntry); + when(mPackageManager.resolveActivity(any(Intent.class), anyInt())).thenReturn( + new ResolveInfo()); + when(mBackend.getRecentApps()).thenReturn(apps); + mAppEntry.info = mApplicationInfo; + + mController.displayPreference(mScreen); + + verify(mCategory).addPreference(argThat(summaryMatches("0 min. ago"))); + } + + private static ArgumentMatcher summaryMatches(String expected) { + return preference -> TextUtils.equals(expected, preference.getSummary()); + } + + // Used for matching an intent with a specific package name. + private static ArgumentMatcher intentMatcher(String packageName) { + return intent -> packageName.equals(intent.getPackage()); + } +}