diff --git a/res/values/strings.xml b/res/values/strings.xml index fa0adac9fd9..f92fd2aca84 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -9214,6 +9214,16 @@ None All + + + {count, plural, offset:2 + =0 {No apps can interrupt} + =1 {{app_1} can interrupt} + =2 {{app_1} and {app_2} can interrupt} + =3 {{app_1}, {app_2}, and {app_3} can interrupt} + other {{app_1}, {app_2}, and # more can interrupt} + } + Allow apps to override diff --git a/src/com/android/settings/notification/NotificationBackend.java b/src/com/android/settings/notification/NotificationBackend.java index 8e6019c3b48..0661de7a5e7 100644 --- a/src/com/android/settings/notification/NotificationBackend.java +++ b/src/com/android/settings/notification/NotificationBackend.java @@ -357,6 +357,19 @@ public class NotificationBackend { } } + /** + * Returns all of a user's packages that have at least one channel that will bypass DND + */ + public List getPackagesBypassingDnd(int userId, + boolean includeConversationChannels) { + try { + return sINM.getPackagesBypassingDnd(userId, includeConversationChannels); + } catch (Exception e) { + Log.w(TAG, "Error calling NoMan", e); + return new ArrayList<>(); + } + } + public void updateChannel(String pkg, int uid, NotificationChannel channel) { try { sINM.updateNotificationChannelForPackage(pkg, uid, channel); diff --git a/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceController.java index 42b58b1346e..581fff57a26 100644 --- a/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceController.java +++ b/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceController.java @@ -20,23 +20,44 @@ import static com.android.settings.notification.modes.ZenModeFragmentBase.MODE_I import android.content.Context; import android.os.Bundle; +import android.util.ArraySet; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.core.text.BidiFormatter; +import androidx.fragment.app.Fragment; import androidx.preference.Preference; import com.android.settings.core.SubSettingLauncher; +import com.android.settings.notification.NotificationBackend; +import com.android.settingslib.applications.ApplicationsState; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; /** * Preference with a link and summary about what apps can break through the mode */ -public class ZenModeAppsLinkPreferenceController extends AbstractZenModePreferenceController { +class ZenModeAppsLinkPreferenceController extends AbstractZenModePreferenceController { + + private static final String TAG = "ZenModeAppsLinkPreferenceController"; private final ZenModeSummaryHelper mSummaryHelper; + private ApplicationsState.Session mAppSession; + private NotificationBackend mNotificationBackend = new NotificationBackend(); + private ZenMode mZenMode; + private Preference mPreference; - public ZenModeAppsLinkPreferenceController(Context context, String key, - ZenModesBackend backend) { + ZenModeAppsLinkPreferenceController(Context context, String key, Fragment host, + ApplicationsState applicationsState, ZenModesBackend backend) { super(context, key, backend); mSummaryHelper = new ZenModeSummaryHelper(mContext, mBackend); + if (applicationsState != null && host != null) { + mAppSession = applicationsState.newSession(mAppSessionCallbacks, host.getLifecycle()); + } } @Override @@ -49,6 +70,84 @@ public class ZenModeAppsLinkPreferenceController extends AbstractZenModePreferen .setSourceMetricsCategory(0) .setArguments(bundle) .toIntent()); - preference.setSummary(mSummaryHelper.getAppsSummary(zenMode)); + mZenMode = zenMode; + mPreference = preference; + triggerUpdateAppsBypassingDndSummaryText(); } + + private void triggerUpdateAppsBypassingDndSummaryText() { + if (mAppSession == null) { + return; + } + + ApplicationsState.AppFilter filter = android.multiuser.Flags.enablePrivateSpaceFeatures() + && android.multiuser.Flags.handleInterleavedSettingsForPrivateSpace() + ? ApplicationsState.FILTER_ENABLED_NOT_QUIET + : ApplicationsState.FILTER_ALL_ENABLED; + // We initiate a rebuild in the background here. Once the rebuild is completed, + // the onRebuildComplete() callback will be invoked, which will trigger the summary text + // to be initialized. + mAppSession.rebuild(filter, ApplicationsState.ALPHA_COMPARATOR, false); + } + + private void updateAppsBypassingDndSummaryText(List apps) { + Set appNames = getAppsBypassingDnd(apps); + mPreference.setSummary(mSummaryHelper.getAppsSummary(mZenMode, appNames)); + } + + @VisibleForTesting + ArraySet getAppsBypassingDnd(@NonNull List apps) { + ArraySet appsBypassingDnd = new ArraySet<>(); + + Map pkgLabelMap = new HashMap(); + for (ApplicationsState.AppEntry entry : apps) { + if (entry.info != null) { + pkgLabelMap.put(entry.info.packageName, entry.label); + } + } + for (String pkg : mNotificationBackend.getPackagesBypassingDnd(mContext.getUserId(), + /* includeConversationChannels= */ false)) { + // Settings may hide some packages from the user, so if they're not present here + // we skip displaying them, even if they bypass dnd. + if (pkgLabelMap.get(pkg) == null) { + continue; + } + appsBypassingDnd.add(BidiFormatter.getInstance().unicodeWrap(pkgLabelMap.get(pkg))); + } + return appsBypassingDnd; + } + + @VisibleForTesting final ApplicationsState.Callbacks mAppSessionCallbacks = + new ApplicationsState.Callbacks() { + + @Override + public void onRunningStateChanged(boolean running) { } + + @Override + public void onPackageListChanged() { + triggerUpdateAppsBypassingDndSummaryText(); + } + + @Override + public void onRebuildComplete(ArrayList apps) { + updateAppsBypassingDndSummaryText(apps); + } + + @Override + public void onPackageIconChanged() { } + + @Override + public void onPackageSizeChanged(String packageName) { } + + @Override + public void onAllSizesComputed() { } + + @Override + public void onLauncherInfoChanged() { } + + @Override + public void onLoadEntriesCompleted() { + triggerUpdateAppsBypassingDndSummaryText(); + } + }; } diff --git a/src/com/android/settings/notification/modes/ZenModeFragment.java b/src/com/android/settings/notification/modes/ZenModeFragment.java index 87165b85d72..e4d81fe51d5 100644 --- a/src/com/android/settings/notification/modes/ZenModeFragment.java +++ b/src/com/android/settings/notification/modes/ZenModeFragment.java @@ -16,11 +16,13 @@ package com.android.settings.notification.modes; +import android.app.Application; import android.app.AutomaticZenRule; import android.app.settings.SettingsEnums; import android.content.Context; import com.android.settings.R; +import com.android.settingslib.applications.ApplicationsState; import com.android.settingslib.core.AbstractPreferenceController; import java.util.ArrayList; @@ -42,7 +44,9 @@ public class ZenModeFragment extends ZenModeFragmentBase { prefControllers.add(new ZenModePeopleLinkPreferenceController( context, "zen_mode_people", mBackend)); prefControllers.add(new ZenModeAppsLinkPreferenceController( - context, "zen_mode_apps", mBackend)); + context, "zen_mode_apps", this, + ApplicationsState.getInstance((Application) context.getApplicationContext()), + mBackend)); prefControllers.add(new ZenModeOtherLinkPreferenceController( context, "zen_other_settings", mBackend)); prefControllers.add(new ZenModeDisplayLinkPreferenceController( diff --git a/src/com/android/settings/notification/modes/ZenModeSummaryHelper.java b/src/com/android/settings/notification/modes/ZenModeSummaryHelper.java index b4075cde656..77f364cd701 100644 --- a/src/com/android/settings/notification/modes/ZenModeSummaryHelper.java +++ b/src/com/android/settings/notification/modes/ZenModeSummaryHelper.java @@ -43,13 +43,18 @@ import android.icu.text.MessageFormat; import android.service.notification.ZenDeviceEffects; import android.service.notification.ZenPolicy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import com.android.settings.R; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.function.Predicate; class ZenModeSummaryHelper { @@ -397,12 +402,13 @@ class ZenModeSummaryHelper { } /** - * Generates a summary to display under the top level "Apps" preference for a mode. + * Generates a summary to display under the top level "Apps" preference for a mode, based + * on the given mode and provided set of apps. */ - public String getAppsSummary(ZenMode zenMode) { - // TODO: b/308819928 - Set summary using priority app list if Selected Apps Chosen. + public @NonNull String getAppsSummary(@NonNull ZenMode zenMode, + @Nullable Set appsBypassing) { if (zenMode.getPolicy().getAllowedChannels() == ZenPolicy.CHANNEL_POLICY_PRIORITY) { - return mContext.getResources().getString(R.string.zen_mode_apps_priority_apps); + return formatAppsList(appsBypassing); } else if (zenMode.getPolicy().getAllowedChannels() == ZenPolicy.CHANNEL_POLICY_NONE) { return mContext.getResources().getString(R.string.zen_mode_apps_none_apps); } else if (zenMode.getPolicy().getAllowedChannels() == ZenMode.CHANNEL_POLICY_ALL) { @@ -410,4 +416,35 @@ class ZenModeSummaryHelper { } return ""; } + + /** + * Generates a formatted string declaring which apps can interrupt in the style of + * "App, App2, and 4 more can interrupt." + * Apps selected for explicit mention are selected in order from the provided set sorted + * alphabetically. + */ + public @NonNull String formatAppsList(@Nullable Set appsBypassingDnd) { + if (appsBypassingDnd == null) { + return mContext.getResources().getString(R.string.zen_mode_apps_priority_apps); + } + final int numAppsBypassingDnd = appsBypassingDnd.size(); + String[] appsBypassingDndArr = appsBypassingDnd.toArray(new String[numAppsBypassingDnd]); + // Sorts the provided apps alphabetically. + Arrays.sort(appsBypassingDndArr); + MessageFormat msgFormat = new MessageFormat( + mContext.getString(R.string.zen_mode_apps_subtext), + Locale.getDefault()); + Map args = new HashMap<>(); + args.put("count", numAppsBypassingDnd); + if (numAppsBypassingDnd >= 1) { + args.put("app_1", appsBypassingDndArr[0]); + if (numAppsBypassingDnd >= 2) { + args.put("app_2", appsBypassingDndArr[1]); + if (numAppsBypassingDnd == 3) { + args.put("app_3", appsBypassingDndArr[2]); + } + } + } + return msgFormat.format(args); + } } diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceControllerTest.java index 67e1f9f919a..c8b11855c72 100644 --- a/tests/robotests/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModeAppsLinkPreferenceControllerTest.java @@ -16,28 +16,51 @@ package com.android.settings.notification.modes; +import static android.app.NotificationManager.INTERRUPTION_FILTER_PRIORITY; + +import static com.android.settings.notification.modes.ZenModeFragmentBase.MODE_ID; + +import static com.google.common.truth.Truth.assertThat; + import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import android.app.AutomaticZenRule; import android.app.Flags; import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; import android.net.Uri; +import android.os.Bundle; import android.platform.test.annotations.EnableFlags; import android.platform.test.flag.junit.SetFlagsRule; import android.service.notification.ZenPolicy; +import androidx.fragment.app.Fragment; import androidx.preference.Preference; +import com.android.settings.SettingsActivity; +import com.android.settings.notification.NotificationBackend; +import com.android.settingslib.applications.ApplicationsState; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; +import com.android.settingslib.widget.SelectorWithWidgetPreference; + import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; 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) @EnableFlags(Flags.FLAG_MODES_UI) @@ -47,7 +70,15 @@ public final class ZenModeAppsLinkPreferenceControllerTest { private Context mContext; @Mock - private ZenModesBackend mBackend; + private ZenModesBackend mZenModesBackend; + + @Mock + private NotificationBackend mNotificationBackend; + + @Mock + private ApplicationsState mApplicationsState; + @Mock + private ApplicationsState.Session mSession; @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); @@ -56,21 +87,109 @@ public final class ZenModeAppsLinkPreferenceControllerTest { public void setup() { MockitoAnnotations.initMocks(this); mContext = RuntimeEnvironment.application; + when(mApplicationsState.newSession(any(), any())).thenReturn(mSession); mController = new ZenModeAppsLinkPreferenceController( - mContext, "controller_key", mBackend); + mContext, "controller_key", mock(Fragment.class), mApplicationsState, + mZenModesBackend); + ReflectionHelpers.setField(mController, "mNotificationBackend", mNotificationBackend); + } + + private ApplicationsState.AppEntry createAppEntry(String packageName, String label) { + ApplicationsState.AppEntry entry = mock(ApplicationsState.AppEntry.class); + entry.info = new ApplicationInfo(); + entry.info.packageName = packageName; + entry.label = label; + entry.info.uid = 0; + return entry; + } + + private ZenMode createPriorityChannelsZenMode() { + return new ZenMode("id", new AutomaticZenRule.Builder("Bedtime", + Uri.parse("bed")) + .setType(AutomaticZenRule.TYPE_BEDTIME) + .setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY) + .setZenPolicy(new ZenPolicy.Builder() + .allowChannels(ZenPolicy.CHANNEL_POLICY_PRIORITY) + .build()) + .build(), true); } @Test - @EnableFlags(Flags.FLAG_MODES_UI) - public void testHasSummary() { - Preference pref = mock(Preference.class); - ZenMode zenMode = new ZenMode("id", - new AutomaticZenRule.Builder("Driving", Uri.parse("drive")) - .setType(AutomaticZenRule.TYPE_DRIVING) - .setZenPolicy(new ZenPolicy.Builder().allowAllSounds().build()) - .build(), true); - mController.updateZenMode(pref, zenMode); - verify(pref).setSummary(any()); + public void testIsAvailable() { + assertThat(mController.isAvailable()).isTrue(); } + @Test + public void testUpdateSetsIntent() { + // Creates the preference + SelectorWithWidgetPreference preference = mock(SelectorWithWidgetPreference.class); + // Create a zen mode that allows priority channels to breakthrough. + ZenMode zenMode = createPriorityChannelsZenMode(); + + // Capture the intent + ArgumentCaptor captor = ArgumentCaptor.forClass(Intent.class); + mController.updateState((Preference) preference, zenMode); + verify(preference).setIntent(captor.capture()); + Intent launcherIntent = captor.getValue(); + + assertThat(launcherIntent.getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)) + .isEqualTo("com.android.settings.notification.modes.ZenModeAppsFragment"); + assertThat(launcherIntent.getIntExtra(MetricsFeatureProvider.EXTRA_SOURCE_METRICS_CATEGORY, + -1)).isEqualTo(0); + + Bundle bundle = launcherIntent.getBundleExtra( + SettingsActivity.EXTRA_SHOW_FRAGMENT_ARGUMENTS); + assertThat(bundle).isNotNull(); + assertThat(bundle.getString(MODE_ID)).isEqualTo("id"); + } + + @Test + public void testGetAppsBypassingDnd() { + ApplicationsState.AppEntry entry = createAppEntry("test", "testLabel"); + ApplicationsState.AppEntry entryConv = createAppEntry("test_conv", "test_convLabel"); + List appEntries = List.of(entry, entryConv); + + when(mNotificationBackend.getPackagesBypassingDnd(mContext.getUserId(), + false)).thenReturn(List.of("test")); + + assertThat(mController.getAppsBypassingDnd(appEntries)).containsExactly("testLabel"); + } + + @Test + public void testUpdateTriggersRebuild() { + // Creates the preference + SelectorWithWidgetPreference preference = mock(SelectorWithWidgetPreference.class); + // Create a zen mode that allows priority channels to breakthrough. + ZenMode zenMode = createPriorityChannelsZenMode(); + + // Create some applications. + ArrayList appEntries = + new ArrayList(); + appEntries.add(createAppEntry("test", "pkgLabel")); + + when(mNotificationBackend.getPackagesBypassingDnd( + mContext.getUserId(), false)) + .thenReturn(List.of("test")); + + // Updates the preference with the zen mode. We expect that this causes the app session + // to trigger a rebuild. + mController.updateZenMode((Preference) preference, zenMode); + verify(mSession).rebuild(any(), any(), eq(false)); + + // Manually triggers the callback that will happen on rebuild. + mController.mAppSessionCallbacks.onRebuildComplete(appEntries); + verify(preference).setSummary("pkgLabel can interrupt"); + } + + @Test + public void testOnPackageListChangedTriggersRebuild() { + mController.mAppSessionCallbacks.onPackageListChanged(); + verify(mSession).rebuild(any(), any(), eq(false)); + } + + @Test + public void testOnLoadEntriesCompletedTriggersRebuild() { + mController.mAppSessionCallbacks.onLoadEntriesCompleted(); + verify(mSession).rebuild(any(), any(), eq(false)); + } } diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModesSummaryHelperTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModesSummaryHelperTest.java index d8c8bf0bfb5..ef8290a4873 100644 --- a/tests/robotests/src/com/android/settings/notification/modes/ZenModesSummaryHelperTest.java +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModesSummaryHelperTest.java @@ -38,6 +38,9 @@ import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; +import java.util.LinkedHashSet; +import java.util.Set; + @RunWith(RobolectricTestRunner.class) public class ZenModesSummaryHelperTest { private Context mContext; @@ -339,7 +342,7 @@ public class ZenModesSummaryHelperTest { .build(); ZenMode zenMode = new ZenMode("id", rule, true); - assertThat(mSummaryHelper.getAppsSummary(zenMode)).isEqualTo("All"); + assertThat(mSummaryHelper.getAppsSummary(zenMode, new LinkedHashSet<>())).isEqualTo("All"); } @Test @@ -353,7 +356,56 @@ public class ZenModesSummaryHelperTest { .build(); ZenMode zenMode = new ZenMode("id", rule, true); - assertThat(mSummaryHelper.getAppsSummary(zenMode)).isEqualTo("None"); + assertThat(mSummaryHelper.getAppsSummary(zenMode, new LinkedHashSet<>())).isEqualTo("None"); + } + + @Test + public void getAppsSummary_priorityAppsNoList() { + AutomaticZenRule rule = new AutomaticZenRule.Builder("Bedtime", Uri.parse("bed")) + .setType(AutomaticZenRule.TYPE_BEDTIME) + .setInterruptionFilter(INTERRUPTION_FILTER_PRIORITY) + .setZenPolicy(new ZenPolicy.Builder() + .allowChannels(ZenPolicy.CHANNEL_POLICY_PRIORITY) + .build()) + .build(); + ZenMode zenMode = new ZenMode("id", rule, true); + + assertThat(mSummaryHelper.getAppsSummary(zenMode, null)).isEqualTo("Selected apps"); + } + + @Test + public void getAppsSummary_formatAppsListEmpty() { + Set apps = new LinkedHashSet<>(); + assertThat(mSummaryHelper.formatAppsList(apps)).isEqualTo("No apps can interrupt"); + } + + @Test + public void getAppsSummary_formatAppsListSingle() { + Set apps = Set.of("My App"); + assertThat(mSummaryHelper.formatAppsList(apps)).isEqualTo("My App can interrupt"); + } + + @Test + public void getAppsSummary_formatAppsListTwo() { + Set apps = Set.of("My App", "SecondApp"); + assertThat(mSummaryHelper.formatAppsList(apps)).isEqualTo("My App and SecondApp " + + "can interrupt"); + } + + @Test + public void getAppsSummary_formatAppsListThree() { + Set apps = Set.of("My App", "SecondApp", "ThirdApp"); + assertThat(mSummaryHelper.formatAppsList(apps)).isEqualTo("My App, SecondApp, " + + "and ThirdApp can interrupt"); + } + + @Test + public void getAppsSummary_formatAppsListMany() { + Set apps = Set.of("My App", "SecondApp", "ThirdApp", "FourthApp", + "FifthApp", "SixthApp"); + // Note that apps are selected alphabetically. + assertThat(mSummaryHelper.formatAppsList(apps)).isEqualTo("FifthApp, FourthApp, " + + "and 4 more can interrupt"); } @Test @@ -366,7 +418,11 @@ public class ZenModesSummaryHelperTest { .build()) .build(); ZenMode zenMode = new ZenMode("id", rule, true); + Set apps = Set.of("My App", "SecondApp", "ThirdApp", "FourthApp", + "FifthApp", "SixthApp"); - assertThat(mSummaryHelper.getAppsSummary(zenMode)).isEqualTo("Selected apps"); + assertThat(mSummaryHelper.getAppsSummary(zenMode, apps)).isEqualTo("FifthApp, FourthApp, " + + "and 4 more can interrupt"); } + }