diff --git a/res/xml/zen_mode_select_bypassing_apps.xml b/res/xml/zen_mode_select_bypassing_apps.xml new file mode 100644 index 00000000000..7b5a84d131f --- /dev/null +++ b/res/xml/zen_mode_select_bypassing_apps.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + diff --git a/src/com/android/settings/notification/modes/ZenModeAddBypassingAppsPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeAddBypassingAppsPreferenceController.java new file mode 100644 index 00000000000..ccd35eca1d5 --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeAddBypassingAppsPreferenceController.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2024 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.modes; + +import android.app.Application; +import android.app.settings.SettingsEnums; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.UserHandle; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.text.BidiFormatter; +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.AppInfoBase; +import com.android.settings.core.PreferenceControllerMixin; +import com.android.settings.core.SubSettingLauncher; +import com.android.settings.notification.NotificationBackend; +import com.android.settings.notification.app.AppChannelsBypassingDndSettings; +import com.android.settingslib.applications.AppUtils; +import com.android.settingslib.applications.ApplicationsState; +import com.android.settingslib.core.AbstractPreferenceController; +import com.android.settingslib.utils.ThreadUtils; +import com.android.settingslib.widget.AppPreference; + +import java.util.ArrayList; +import java.util.List; + + +/** + * When clicked, populates the PreferenceScreen with apps that aren't already bypassing DND. The + * user can click on these Preferences to allow notification channels from the app to bypass DND. + */ +public class ZenModeAddBypassingAppsPreferenceController extends AbstractPreferenceController + implements PreferenceControllerMixin { + + public static final String KEY_NO_APPS = "add_none"; + private static final String KEY = "zen_mode_non_bypassing_apps_list"; + private static final String KEY_ADD = "zen_mode_bypassing_apps_add"; + @Nullable private final NotificationBackend mNotificationBackend; + + @Nullable @VisibleForTesting ApplicationsState mApplicationsState; + @VisibleForTesting PreferenceScreen mPreferenceScreen; + @VisibleForTesting PreferenceCategory mPreferenceCategory; + @VisibleForTesting Context mPrefContext; + + private Preference mAddPreference; + private ApplicationsState.Session mAppSession; + @Nullable private Fragment mHostFragment; + + public ZenModeAddBypassingAppsPreferenceController(Context context, @Nullable Application app, + @Nullable Fragment host, @Nullable NotificationBackend notificationBackend) { + this(context, app == null ? null : ApplicationsState.getInstance(app), host, + notificationBackend); + } + + private ZenModeAddBypassingAppsPreferenceController(Context context, + @Nullable ApplicationsState appState, @Nullable Fragment host, + @Nullable NotificationBackend notificationBackend) { + super(context); + mNotificationBackend = notificationBackend; + mApplicationsState = appState; + mHostFragment = host; + } + + @Override + public void displayPreference(PreferenceScreen screen) { + mPreferenceScreen = screen; + mAddPreference = screen.findPreference(KEY_ADD); + mAddPreference.setOnPreferenceClickListener(new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference preference) { + mAddPreference.setVisible(false); + if (mApplicationsState != null && mHostFragment != null) { + mAppSession = mApplicationsState.newSession(mAppSessionCallbacks, + mHostFragment.getLifecycle()); + } + return true; + } + }); + mPrefContext = screen.getContext(); + super.displayPreference(screen); + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public String getPreferenceKey() { + return KEY; + } + + /** + * Call this method to trigger the app list to refresh. + */ + public void updateAppList() { + if (mAppSession == null) { + return; + } + + ApplicationsState.AppFilter filter = android.multiuser.Flags.enablePrivateSpaceFeatures() + && android.multiuser.Flags.handleInterleavedSettingsForPrivateSpace() + ? ApplicationsState.FILTER_ENABLED_NOT_QUIET + : ApplicationsState.FILTER_ALL_ENABLED; + mAppSession.rebuild(filter, ApplicationsState.ALPHA_COMPARATOR); + } + + // Set the icon for the given preference to the entry icon from cache if available, or look + // it up. + private void updateIcon(Preference pref, ApplicationsState.AppEntry entry) { + synchronized (entry) { + final Drawable cachedIcon = AppUtils.getIconFromCache(entry); + if (cachedIcon != null && entry.mounted) { + pref.setIcon(cachedIcon); + } else { + ThreadUtils.postOnBackgroundThread(() -> { + final Drawable icon = AppUtils.getIcon(mPrefContext, entry); + if (icon != null) { + ThreadUtils.postOnMainThread(() -> pref.setIcon(icon)); + } + }); + } + } + } + + @VisibleForTesting + void updateAppList(List apps) { + if (apps == null) { + return; + } + + if (mPreferenceCategory == null) { + mPreferenceCategory = new PreferenceCategory(mPrefContext); + mPreferenceCategory.setTitle(R.string.zen_mode_bypassing_apps_add_header); + mPreferenceScreen.addPreference(mPreferenceCategory); + } + + boolean doAnyAppsPassCriteria = false; + for (ApplicationsState.AppEntry app : apps) { + String pkg = app.info.packageName; + final String key = getKey(pkg, app.info.uid); + final int appChannels = mNotificationBackend.getChannelCount(pkg, app.info.uid); + final int appChannelsBypassingDnd = mNotificationBackend + .getNotificationChannelsBypassingDnd(pkg, app.info.uid).getList().size(); + if (appChannelsBypassingDnd == 0 && appChannels > 0) { + doAnyAppsPassCriteria = true; + } + + Preference pref = mPreferenceCategory.findPreference(key); + + if (pref == null) { + if (appChannelsBypassingDnd == 0 && appChannels > 0) { + // does not exist but should + pref = new AppPreference(mPrefContext); + pref.setKey(key); + pref.setOnPreferenceClickListener(preference -> { + Bundle args = new Bundle(); + args.putString(AppInfoBase.ARG_PACKAGE_NAME, app.info.packageName); + args.putInt(AppInfoBase.ARG_PACKAGE_UID, app.info.uid); + new SubSettingLauncher(mContext) + .setDestination(AppChannelsBypassingDndSettings.class.getName()) + .setArguments(args) + .setResultListener(mHostFragment, 0) + .setUserHandle(new UserHandle(UserHandle.getUserId(app.info.uid))) + .setSourceMetricsCategory( + SettingsEnums.NOTIFICATION_ZEN_MODE_OVERRIDING_APP) + .launch(); + return true; + }); + pref.setTitle(BidiFormatter.getInstance().unicodeWrap(app.label)); + updateIcon(pref, app); + mPreferenceCategory.addPreference(pref); + } + } else if (appChannelsBypassingDnd != 0 || appChannels == 0) { + // exists but shouldn't anymore + mPreferenceCategory.removePreference(pref); + } + } + + Preference pref = mPreferenceCategory.findPreference(KEY_NO_APPS); + if (!doAnyAppsPassCriteria) { + if (pref == null) { + pref = new Preference(mPrefContext); + pref.setKey(KEY_NO_APPS); + pref.setTitle(R.string.zen_mode_bypassing_apps_none); + } + mPreferenceCategory.addPreference(pref); + } else if (pref != null) { + mPreferenceCategory.removePreference(pref); + } + } + + static String getKey(String pkg, int uid) { + return "add|" + pkg + "|" + uid; + } + + private final ApplicationsState.Callbacks mAppSessionCallbacks = + new ApplicationsState.Callbacks() { + + @Override + public void onRunningStateChanged(boolean running) { + + } + + @Override + public void onPackageListChanged() { + + } + + @Override + public void onRebuildComplete(ArrayList apps) { + updateAppList(apps); + } + + @Override + public void onPackageIconChanged() { + updateAppList(); + } + + @Override + public void onPackageSizeChanged(String packageName) { + + } + + @Override + public void onAllSizesComputed() { } + + @Override + public void onLauncherInfoChanged() { + + } + + @Override + public void onLoadEntriesCompleted() { + updateAppList(); + } + }; +} diff --git a/src/com/android/settings/notification/modes/ZenModeAllBypassingAppsPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeAllBypassingAppsPreferenceController.java new file mode 100644 index 00000000000..922ac5ecf5b --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeAllBypassingAppsPreferenceController.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2024 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.modes; + +import android.app.Application; +import android.app.settings.SettingsEnums; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.os.UserHandle; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.text.BidiFormatter; +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.AppInfoBase; +import com.android.settings.core.PreferenceControllerMixin; +import com.android.settings.core.SubSettingLauncher; +import com.android.settings.notification.NotificationBackend; +import com.android.settings.notification.app.AppChannelsBypassingDndSettings; +import com.android.settingslib.applications.AppUtils; +import com.android.settingslib.applications.ApplicationsState; +import com.android.settingslib.core.AbstractPreferenceController; +import com.android.settingslib.utils.ThreadUtils; +import com.android.settingslib.widget.AppPreference; + +import java.util.ArrayList; +import java.util.List; + +/** + * Adds a preference to the PreferenceScreen for each notification channel that can bypass DND. + */ +public class ZenModeAllBypassingAppsPreferenceController extends AbstractPreferenceController + implements PreferenceControllerMixin { + public static final String KEY_NO_APPS = "all_none"; + private static final String KEY = "zen_mode_bypassing_apps_list"; + + @Nullable private final NotificationBackend mNotificationBackend; + + @Nullable @VisibleForTesting ApplicationsState mApplicationsState; + @VisibleForTesting PreferenceCategory mPreferenceCategory; + @VisibleForTesting Context mPrefContext; + + private ApplicationsState.Session mAppSession; + @Nullable private Fragment mHostFragment; + + public ZenModeAllBypassingAppsPreferenceController(Context context, @Nullable Application app, + @Nullable Fragment host, @Nullable NotificationBackend notificationBackend) { + this(context, app == null ? null : ApplicationsState.getInstance(app), host, + notificationBackend); + } + + private ZenModeAllBypassingAppsPreferenceController(Context context, + @Nullable ApplicationsState appState, @Nullable Fragment host, + @Nullable NotificationBackend notificationBackend) { + super(context); + mNotificationBackend = notificationBackend; + mApplicationsState = appState; + mHostFragment = host; + + if (mApplicationsState != null && host != null) { + mAppSession = mApplicationsState.newSession(mAppSessionCallbacks, host.getLifecycle()); + } + } + + @Override + public void displayPreference(PreferenceScreen screen) { + mPreferenceCategory = screen.findPreference(KEY); + mPrefContext = screen.getContext(); + updateAppList(); + super.displayPreference(screen); + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public String getPreferenceKey() { + return KEY; + } + + /** + * Call this method to trigger the app list to refresh. + */ + public void updateAppList() { + if (mAppSession == null) { + return; + } + + ApplicationsState.AppFilter filter = android.multiuser.Flags.enablePrivateSpaceFeatures() + && android.multiuser.Flags.handleInterleavedSettingsForPrivateSpace() + ? ApplicationsState.FILTER_ENABLED_NOT_QUIET + : ApplicationsState.FILTER_ALL_ENABLED; + mAppSession.rebuild(filter, ApplicationsState.ALPHA_COMPARATOR); + } + + // Set the icon for the given preference to the entry icon from cache if available, or look + // it up. + private void updateIcon(Preference pref, ApplicationsState.AppEntry entry) { + synchronized (entry) { + final Drawable cachedIcon = AppUtils.getIconFromCache(entry); + if (cachedIcon != null && entry.mounted) { + pref.setIcon(cachedIcon); + } else { + ThreadUtils.postOnBackgroundThread(() -> { + final Drawable icon = AppUtils.getIcon(mPrefContext, entry); + if (icon != null) { + ThreadUtils.postOnMainThread(() -> pref.setIcon(icon)); + } + }); + } + } + } + + @VisibleForTesting + void updateAppList(List apps) { + if (mPreferenceCategory == null || apps == null) { + return; + } + + boolean doAnyAppsPassCriteria = false; + for (ApplicationsState.AppEntry app : apps) { + String pkg = app.info.packageName; + final String key = getKey(pkg, app.info.uid); + final int appChannels = mNotificationBackend.getChannelCount(pkg, app.info.uid); + final int appChannelsBypassingDnd = mNotificationBackend + .getNotificationChannelsBypassingDnd(pkg, app.info.uid).getList().size(); + if (appChannelsBypassingDnd > 0) { + doAnyAppsPassCriteria = true; + } + + Preference pref = mPreferenceCategory.findPreference(key); + if (pref == null) { + if (appChannelsBypassingDnd > 0) { + // does not exist but should + pref = new AppPreference(mPrefContext); + pref.setKey(key); + pref.setOnPreferenceClickListener(preference -> { + Bundle args = new Bundle(); + args.putString(AppInfoBase.ARG_PACKAGE_NAME, app.info.packageName); + args.putInt(AppInfoBase.ARG_PACKAGE_UID, app.info.uid); + new SubSettingLauncher(mContext) + .setDestination(AppChannelsBypassingDndSettings.class.getName()) + .setArguments(args) + .setUserHandle(UserHandle.getUserHandleForUid(app.info.uid)) + .setResultListener(mHostFragment, 0) + .setSourceMetricsCategory( + SettingsEnums.NOTIFICATION_ZEN_MODE_OVERRIDING_APP) + .launch(); + return true; + }); + pref.setTitle(BidiFormatter.getInstance().unicodeWrap(app.label)); + updateIcon(pref, app); + if (appChannels > appChannelsBypassingDnd) { + pref.setSummary(R.string.zen_mode_bypassing_apps_summary_some); + } else { + pref.setSummary(R.string.zen_mode_bypassing_apps_summary_all); + } + mPreferenceCategory.addPreference(pref); + } + } else if (appChannelsBypassingDnd == 0) { + // exists but shouldn't anymore + mPreferenceCategory.removePreference(pref); + } + } + + Preference pref = mPreferenceCategory.findPreference(KEY_NO_APPS); + if (!doAnyAppsPassCriteria) { + if (pref == null) { + pref = new Preference(mPrefContext); + pref.setKey(KEY_NO_APPS); + pref.setTitle(R.string.zen_mode_bypassing_apps_none); + } + mPreferenceCategory.addPreference(pref); + } else if (pref != null) { + mPreferenceCategory.removePreference(pref); + } + } + + /** + * Create a unique key to idenfity an AppPreference + */ + static String getKey(String pkg, int uid) { + return "all|" + pkg + "|" + uid; + } + + private final ApplicationsState.Callbacks mAppSessionCallbacks = + new ApplicationsState.Callbacks() { + + @Override + public void onRunningStateChanged(boolean running) { + } + + @Override + public void onPackageListChanged() { + } + + @Override + public void onRebuildComplete(ArrayList apps) { + updateAppList(apps); + } + + @Override + public void onPackageIconChanged() { + } + + @Override + public void onPackageSizeChanged(String packageName) { + } + + @Override + public void onAllSizesComputed() { } + + @Override + public void onLauncherInfoChanged() { + } + + @Override + public void onLoadEntriesCompleted() { + updateAppList(); + } + }; +} diff --git a/src/com/android/settings/notification/modes/ZenModeAppsPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeAppsPreferenceController.java index 2b5a479b840..704bce04504 100644 --- a/src/com/android/settings/notification/modes/ZenModeAppsPreferenceController.java +++ b/src/com/android/settings/notification/modes/ZenModeAppsPreferenceController.java @@ -16,8 +16,11 @@ package com.android.settings.notification.modes; +import static com.android.settings.notification.modes.ZenModeFragmentBase.MODE_ID; + import android.app.settings.SettingsEnums; import android.content.Context; +import android.os.Bundle; import android.service.notification.ZenPolicy; import androidx.annotation.NonNull; @@ -28,7 +31,6 @@ import androidx.preference.PreferenceScreen; import androidx.preference.TwoStatePreference; import com.android.settings.core.SubSettingLauncher; -import com.android.settings.notification.zen.ZenModeBypassingAppsSettings; import com.android.settingslib.widget.SelectorWithWidgetPreference; public class ZenModeAppsPreferenceController extends @@ -38,6 +40,8 @@ public class ZenModeAppsPreferenceController extends static final String KEY_NONE = "zen_mode_apps_none"; static final String KEY_ALL = "zen_mode_apps_all"; + String mModeId; + public ZenModeAppsPreferenceController(@NonNull Context context, @NonNull String key, @Nullable ZenModesBackend backend) { @@ -62,6 +66,7 @@ public class ZenModeAppsPreferenceController extends @Override public void updateState(Preference preference, @NonNull ZenMode zenMode) { + mModeId = zenMode.getId(); TwoStatePreference pref = (TwoStatePreference) preference; switch (getPreferenceKey()) { case KEY_PRIORITY: @@ -107,10 +112,15 @@ public class ZenModeAppsPreferenceController extends }; private void launchPrioritySettings() { + Bundle bundle = new Bundle(); + if (mModeId != null) { + bundle.putString(MODE_ID, mModeId); + } // TODO(b/332937635): Update metrics category new SubSettingLauncher(mContext) - .setDestination(ZenModeBypassingAppsSettings.class.getName()) + .setDestination(ZenModeSelectBypassingAppsFragment.class.getName()) .setSourceMetricsCategory(SettingsEnums.SETTINGS_ZEN_NOTIFICATIONS) + .setArguments(bundle) .launch(); } } diff --git a/src/com/android/settings/notification/modes/ZenModeSelectBypassingAppsFragment.java b/src/com/android/settings/notification/modes/ZenModeSelectBypassingAppsFragment.java new file mode 100644 index 00000000000..8b682b92f1a --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeSelectBypassingAppsFragment.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2024 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.modes; + +import android.app.Activity; +import android.app.Application; +import android.app.settings.SettingsEnums; +import android.content.Context; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.android.settings.R; +import com.android.settings.notification.NotificationBackend; +import com.android.settings.search.BaseSearchIndexProvider; +import com.android.settingslib.core.AbstractPreferenceController; +import com.android.settingslib.search.Indexable; +import com.android.settingslib.search.SearchIndexable; + +import java.util.ArrayList; +import java.util.List; + +@SearchIndexable +public class ZenModeSelectBypassingAppsFragment extends ZenModeFragmentBase implements + Indexable { + private static final String TAG = "ZenBypassingApps"; + + @Override + protected List createPreferenceControllers(Context context) { + final Activity activity = getActivity(); + final Application app; + if (activity != null) { + app = activity.getApplication(); + } else { + app = null; + } + return buildPreferenceControllers(context, app, this, new NotificationBackend()); + } + + private static List buildPreferenceControllers(Context context, + @Nullable Application app, @Nullable Fragment host, + @Nullable NotificationBackend notificationBackend) { + final List controllers = new ArrayList<>(); + controllers.add(new ZenModeAllBypassingAppsPreferenceController(context, app, host, + notificationBackend)); + controllers.add(new ZenModeAddBypassingAppsPreferenceController(context, app, host, + notificationBackend)); + return controllers; + } + + @Override + protected int getPreferenceScreenResId() { + return R.xml.zen_mode_select_bypassing_apps; + } + + @Override + protected String getLogTag() { + return TAG; + } + + @Override + public int getMetricsCategory() { + // TODO(b/332937635): Update metrics category + return SettingsEnums.NOTIFICATION_ZEN_MODE_OVERRIDING_APPS; + } + + /** + * For Search. + */ + public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER = + new BaseSearchIndexProvider(R.xml.zen_mode_select_bypassing_apps) { + + @Override + public List createPreferenceControllers( + Context context) { + return buildPreferenceControllers(context, null, null, null); + } + }; +} diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModeAddBypassingAppsPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModeAddBypassingAppsPreferenceControllerTest.java new file mode 100644 index 00000000000..bca1ccf58c6 --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModeAddBypassingAppsPreferenceControllerTest.java @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2024 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.modes; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.NotificationChannel; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.ParceledListSlice; + +import androidx.fragment.app.Fragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; + +import com.android.settings.notification.NotificationBackend; +import com.android.settingslib.applications.ApplicationsState; + +import org.junit.Before; +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 java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class ZenModeAddBypassingAppsPreferenceControllerTest { + + @Mock + private NotificationBackend mBackend; + @Mock + private PreferenceCategory mPreferenceCategory; + @Mock + private ApplicationsState mApplicationState; + private ZenModeAddBypassingAppsPreferenceController mController; + private Context mContext; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + mContext = RuntimeEnvironment.application; + + mController = new ZenModeAddBypassingAppsPreferenceController( + mContext, null, mock(Fragment.class), mBackend); + mController.mPreferenceCategory = mPreferenceCategory; + mController.mApplicationsState = mApplicationState; + mController.mPrefContext = mContext; + } + + @Test + public void testIsAvailable() { + assertThat(mController.isAvailable()).isTrue(); + } + + @Test + public void testUpdateAppList() { + // GIVEN there's an app with bypassing channels, app without any channels, and then an app + // with notification channels but none that can bypass DND + ApplicationsState.AppEntry appWithBypassingChannels = + mock(ApplicationsState.AppEntry.class); + appWithBypassingChannels.info = new ApplicationInfo(); + appWithBypassingChannels.info.packageName = "appWithBypassingChannels"; + appWithBypassingChannels.info.uid = 0; + when(mBackend.getNotificationChannelsBypassingDnd( + appWithBypassingChannels.info.packageName, + appWithBypassingChannels.info.uid)) + .thenReturn(new ParceledListSlice<>( + Arrays.asList(mock(NotificationChannel.class)))); + when(mBackend.getChannelCount( + appWithBypassingChannels.info.packageName, + appWithBypassingChannels.info.uid)) + .thenReturn(5); + + ApplicationsState.AppEntry appWithoutChannels = mock(ApplicationsState.AppEntry.class); + appWithoutChannels.info = new ApplicationInfo(); + appWithoutChannels.info.packageName = "appWithoutChannels"; + appWithoutChannels.info.uid = 0; + when(mBackend.getChannelCount( + appWithoutChannels.info.packageName, + appWithoutChannels.info.uid)) + .thenReturn(0); + when(mBackend.getNotificationChannelsBypassingDnd( + appWithoutChannels.info.packageName, + appWithoutChannels.info.uid)) + .thenReturn(new ParceledListSlice<>(new ArrayList<>())); + + ApplicationsState.AppEntry appWithChannelsNoneBypassing = + mock(ApplicationsState.AppEntry.class); + appWithChannelsNoneBypassing.info = new ApplicationInfo(); + appWithChannelsNoneBypassing.info.packageName = "appWithChannelsNoneBypassing"; + appWithChannelsNoneBypassing.info.uid = 0; + when(mBackend.getChannelCount( + appWithChannelsNoneBypassing.info.packageName, + appWithChannelsNoneBypassing.info.uid)) + .thenReturn(5); + when(mBackend.getNotificationChannelsBypassingDnd( + appWithChannelsNoneBypassing.info.packageName, + appWithChannelsNoneBypassing.info.uid)) + .thenReturn(new ParceledListSlice<>(new ArrayList<>())); + + List appEntries = new ArrayList<>(); + appEntries.add(appWithBypassingChannels); + appEntries.add(appWithoutChannels); + appEntries.add(appWithChannelsNoneBypassing); + + // WHEN the controller updates the app list with the app entries + mController.updateAppList(appEntries); + + // THEN only the appWithChannelsNoneBypassing makes it to the app list + ArgumentCaptor prefCaptor = ArgumentCaptor.forClass(Preference.class); + verify(mPreferenceCategory).addPreference(prefCaptor.capture()); + + Preference pref = prefCaptor.getValue(); + assertThat(pref.getKey()).isEqualTo( + ZenModeAddBypassingAppsPreferenceController.getKey( + appWithChannelsNoneBypassing.info.packageName, + appWithChannelsNoneBypassing.info.uid)); + } + + @Test + public void testUpdateAppList_nullApps() { + mController.updateAppList(null); + verify(mPreferenceCategory, never()).addPreference(any()); + } + + @Test + public void testUpdateAppList_emptyAppList() { + // WHEN there are no apps + mController.updateAppList(new ArrayList<>()); + + // THEN only the appWithChannelsNoneBypassing makes it to the app list + ArgumentCaptor prefCaptor = ArgumentCaptor.forClass(Preference.class); + verify(mPreferenceCategory).addPreference(prefCaptor.capture()); + + Preference pref = prefCaptor.getValue(); + assertThat(pref.getKey()).isEqualTo( + ZenModeAddBypassingAppsPreferenceController.KEY_NO_APPS); + } + + // TODO(b/331624810): Add tests to verify updateAppList() when the filter is + // ApplicationsState.FILTER_ENABLED_NOT_QUIET +} diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModeAllBypassingAppsPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModeAllBypassingAppsPreferenceControllerTest.java new file mode 100644 index 00000000000..3114a2d01ca --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModeAllBypassingAppsPreferenceControllerTest.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 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.modes; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.NotificationChannel; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.ParceledListSlice; + +import androidx.fragment.app.Fragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; + +import com.android.settings.notification.NotificationBackend; +import com.android.settingslib.applications.ApplicationsState; + +import org.junit.Before; +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 java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class ZenModeAllBypassingAppsPreferenceControllerTest { + private ZenModeAllBypassingAppsPreferenceController mController; + + private Context mContext; + @Mock + private NotificationBackend mBackend; + @Mock + private PreferenceCategory mPreferenceCategory; + @Mock + private ApplicationsState mApplicationState; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + mContext = RuntimeEnvironment.application; + + mController = new ZenModeAllBypassingAppsPreferenceController( + mContext, null, mock(Fragment.class), mBackend); + mController.mPreferenceCategory = mPreferenceCategory; + mController.mApplicationsState = mApplicationState; + mController.mPrefContext = mContext; + } + + @Test + public void testIsAvailable() { + assertThat(mController.isAvailable()).isTrue(); + } + + @Test + public void testUpdateAppList() { + // WHEN there's two apps with notification channels that bypass DND + ApplicationsState.AppEntry entry1 = mock(ApplicationsState.AppEntry.class); + entry1.info = new ApplicationInfo(); + entry1.info.packageName = "test"; + entry1.info.uid = 0; + + ApplicationsState.AppEntry entry2 = mock(ApplicationsState.AppEntry.class); + entry2.info = new ApplicationInfo(); + entry2.info.packageName = "test2"; + entry2.info.uid = 0; + + List appEntries = new ArrayList<>(); + appEntries.add(entry1); + appEntries.add(entry2); + List channelsBypassing = new ArrayList<>(); + channelsBypassing.add(mock(NotificationChannel.class)); + channelsBypassing.add(mock(NotificationChannel.class)); + when(mBackend.getNotificationChannelsBypassingDnd(anyString(), + anyInt())).thenReturn(new ParceledListSlice<>(channelsBypassing)); + + // THEN there's are two preferences + mController.updateAppList(appEntries); + verify(mPreferenceCategory, times(2)).addPreference(any()); + } + + @Test + public void testUpdateAppList_nullApps() { + mController.updateAppList(null); + verify(mPreferenceCategory, never()).addPreference(any()); + } + + @Test + public void testUpdateAppList_emptyAppList() { + // WHEN there are no apps + mController.updateAppList(new ArrayList<>()); + + // THEN only the appWithChannelsNoneBypassing makes it to the app list + ArgumentCaptor prefCaptor = ArgumentCaptor.forClass(Preference.class); + verify(mPreferenceCategory).addPreference(prefCaptor.capture()); + + Preference pref = prefCaptor.getValue(); + assertThat(pref.getKey()).isEqualTo( + ZenModeAllBypassingAppsPreferenceController.KEY_NO_APPS); + } +}