diff --git a/res/xml/bundle_notifications_settings.xml b/res/xml/bundle_notifications_settings.xml index 34efd7dd49b..3ff8c053273 100644 --- a/res/xml/bundle_notifications_settings.xml +++ b/res/xml/bundle_notifications_settings.xml @@ -56,4 +56,27 @@ android:key="recs" android:title="@*android:string/recs_notification_channel_label" settings:controller="com.android.settings.notification.BundleTypePreferenceController"/> + + + + + + + + + + diff --git a/res/xml/summarization_notifications_settings.xml b/res/xml/summarization_notifications_settings.xml index c6de6265709..af9598042ac 100644 --- a/res/xml/summarization_notifications_settings.xml +++ b/res/xml/summarization_notifications_settings.xml @@ -36,4 +36,29 @@ android:key="global_pref" android:title="@string/notification_summarization_main_control_title" settings:controller="com.android.settings.notification.SummarizationGlobalPreferenceController" /> + + + + + + + + + + + + diff --git a/src/com/android/settings/Settings.java b/src/com/android/settings/Settings.java index ee866babffc..c8b0783ece5 100644 --- a/src/com/android/settings/Settings.java +++ b/src/com/android/settings/Settings.java @@ -358,6 +358,8 @@ public class Settings extends SettingsActivity { public static class AppBubbleNotificationSettingsActivity extends SettingsActivity { /* empty */ } public static class NotificationAssistantSettingsActivity extends SettingsActivity{ /* empty */ } public static class NotificationAppListActivity extends SettingsActivity { /* empty */ } + public static class NotificationExcludeSummarizationActivity extends SettingsActivity { /* empty */ } + public static class NotificationExcludeClassificationActivity extends SettingsActivity { /* empty */ } /** Activity to manage Cloned Apps page */ public static class ClonedAppsListActivity extends SettingsActivity { /* empty */ } /** Activity to manage Aspect Ratio app list page */ diff --git a/src/com/android/settings/applications/manageapplications/ManageApplications.java b/src/com/android/settings/applications/manageapplications/ManageApplications.java index b837e1e9c5d..d3d532f2dd4 100644 --- a/src/com/android/settings/applications/manageapplications/ManageApplications.java +++ b/src/com/android/settings/applications/manageapplications/ManageApplications.java @@ -270,6 +270,8 @@ public class ManageApplications extends InstrumentedFragment public static final int LIST_TYPE_NFC_TAG_APPS = 18; public static final int LIST_TYPE_TURN_SCREEN_ON = 19; public static final int LIST_TYPE_USER_ASPECT_RATIO_APPS = 20; + public static final int LIST_TYPE_NOTIFICATION_EXCLUDE_SUMMARIZATION = 21; + public static final int LIST_TYPE_NOTIFICATION_EXCLUDE_CLASSIFICATION = 22; // List types that should show instant apps. public static final Set LIST_TYPES_WITH_INSTANT = new ArraySet<>(Arrays.asList( diff --git a/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt b/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt index dca115b97c0..41b92dde868 100644 --- a/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt +++ b/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt @@ -31,6 +31,8 @@ import com.android.settings.Settings.ManageExternalSourcesActivity import com.android.settings.Settings.ManageExternalStorageActivity import com.android.settings.Settings.MediaManagementAppsActivity import com.android.settings.Settings.NotificationAppListActivity +import com.android.settings.Settings.NotificationExcludeClassificationActivity +import com.android.settings.Settings.NotificationExcludeSummarizationActivity import com.android.settings.Settings.NotificationReviewPermissionsActivity import com.android.settings.Settings.OverlaySettingsActivity import com.android.settings.Settings.StorageUseActivity @@ -44,6 +46,8 @@ import com.android.settings.applications.manageapplications.ManageApplications.L import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_USER_ASPECT_RATIO_APPS import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_BATTERY_OPTIMIZATION import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_CLONED_APPS +import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_NOTIFICATION_EXCLUDE_CLASSIFICATION +import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_NOTIFICATION_EXCLUDE_SUMMARIZATION import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_GAMES import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_HIGH_POWER import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_LONG_BACKGROUND_TASKS @@ -99,6 +103,9 @@ object ManageApplicationsUtil { ChangeNfcTagAppsActivity::class to LIST_TYPE_NFC_TAG_APPS, TurnScreenOnSettingsActivity::class to LIST_TYPE_TURN_SCREEN_ON, UserAspectRatioAppListActivity::class to LIST_TYPE_USER_ASPECT_RATIO_APPS, + NotificationExcludeSummarizationActivity::class to LIST_TYPE_NOTIFICATION_EXCLUDE_SUMMARIZATION, + NotificationExcludeClassificationActivity::class to LIST_TYPE_NOTIFICATION_EXCLUDE_CLASSIFICATION, + ) @JvmField @@ -117,7 +124,7 @@ object ManageApplicationsUtil { LIST_TYPE_MEDIA_MANAGEMENT_APPS -> MediaManagementAppsAppListProvider.getAppListRoute() LIST_TYPE_ALARMS_AND_REMINDERS -> AlarmsAndRemindersAppListProvider.getAppListRoute() LIST_TYPE_WIFI_ACCESS -> WifiControlAppListProvider.getAppListRoute() - LIST_TYPE_NOTIFICATION -> AppListNotificationsPageProvider.name + LIST_TYPE_NOTIFICATION -> AppListNotificationsPageProvider.AllApps.name LIST_TYPE_APPS_LOCALE -> AppLanguagesPageProvider.name LIST_TYPE_MAIN -> AllAppListPageProvider.name LIST_TYPE_NFC_TAG_APPS -> NfcTagAppsSettingsProvider.getAppListRoute() @@ -128,6 +135,8 @@ object ManageApplicationsUtil { //LIST_TYPE_STORAGE -> StorageAppListPageProvider.Apps.name //LIST_TYPE_GAMES -> StorageAppListPageProvider.Games.name LIST_TYPE_BATTERY_OPTIMIZATION -> BatteryOptimizationModeAppListPageProvider.name + LIST_TYPE_NOTIFICATION_EXCLUDE_SUMMARIZATION -> AppListNotificationsPageProvider.ExcludeSummarization.name + LIST_TYPE_NOTIFICATION_EXCLUDE_CLASSIFICATION -> AppListNotificationsPageProvider.ExcludeClassification.name else -> null } } diff --git a/src/com/android/settings/notification/AdjustmentExcludedAppsPreferenceController.java b/src/com/android/settings/notification/AdjustmentExcludedAppsPreferenceController.java new file mode 100644 index 00000000000..b9cf9737be6 --- /dev/null +++ b/src/com/android/settings/notification/AdjustmentExcludedAppsPreferenceController.java @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2025 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 android.service.notification.Adjustment.KEY_SUMMARIZATION; +import static android.service.notification.Adjustment.KEY_TYPE; + +import android.app.Flags; +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.service.notification.Adjustment; + +import androidx.annotation.NonNull; +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.core.BasePreferenceController; +import com.android.settings.core.PreferenceControllerMixin; +import com.android.settingslib.applications.AppUtils; +import com.android.settingslib.applications.ApplicationsState; +import com.android.settingslib.utils.ThreadUtils; + +import com.google.common.util.concurrent.ListenableFuture; + +import java.util.ArrayList; +import java.util.List; + +/** + * Adds a preference to the PreferenceCategory for every app excluded from an adjustment key + */ +public class AdjustmentExcludedAppsPreferenceController extends BasePreferenceController + implements PreferenceControllerMixin { + + @NonNull private NotificationBackend mBackend; + + @Nullable String mAdjustmentKey; + @Nullable @VisibleForTesting ApplicationsState mApplicationsState; + @VisibleForTesting PreferenceCategory mPreferenceCategory; + @VisibleForTesting Context mPrefContext; + + private ApplicationsState.Session mAppSession; + + public AdjustmentExcludedAppsPreferenceController(@NonNull Context context, + @NonNull String preferenceKey) { + super(context, preferenceKey); + mBackend = new NotificationBackend(); + } + + protected void onAttach(@Nullable ApplicationsState appState, @Nullable Fragment host, + @NonNull NotificationBackend helperBackend, @Adjustment.Keys String adjustment) { + mApplicationsState = appState; + mBackend = helperBackend; + mAdjustmentKey = adjustment; + + if (mApplicationsState != null && host != null) { + mAppSession = mApplicationsState.newSession(mAppSessionCallbacks, host.getLifecycle()); + } + } + + @Override + public void displayPreference(@NonNull PreferenceScreen screen) { + mPreferenceCategory = screen.findPreference(getPreferenceKey()); + mPrefContext = screen.getContext(); + updateAppList(); + super.displayPreference(screen); + } + + @Override + public int getAvailabilityStatus() { + if (!(Flags.nmSummarization() || Flags.nmSummarizationUi() + || Flags.notificationClassificationUi())) { + return CONDITIONALLY_UNAVAILABLE; + } + if (KEY_SUMMARIZATION.equals(mAdjustmentKey) + && mBackend.isNotificationSummarizationSupported()) { + return AVAILABLE; + } + if (KEY_TYPE.equals(mAdjustmentKey) && mBackend.isNotificationBundlingSupported()) { + return AVAILABLE; + } + return CONDITIONALLY_UNAVAILABLE; + } + + /** + * 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 { + ListenableFuture unused = 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; + } + + List excludedApps = List.of(mBackend.getAdjustmentDeniedPackages(mAdjustmentKey)); + + for (ApplicationsState.AppEntry app : apps) { + String pkg = app.info.packageName; + final String key = getKey(pkg, app.info.uid); + boolean doesAppPassCriteria = false; + + if (excludedApps.contains(pkg)) { + doesAppPassCriteria = true; + } + Preference pref = mPreferenceCategory.findPreference(key); + if (pref == null) { + if (doesAppPassCriteria) { + // does not exist but should + pref = new Preference(mPrefContext); + pref.setKey(key); + pref.setTitle(BidiFormatter.getInstance().unicodeWrap(app.label)); + updateIcon(pref, app); + mPreferenceCategory.addPreference(pref); + } + } else if (!doesAppPassCriteria) { + // exists but shouldn't anymore + mPreferenceCategory.removePreference(pref); + } + } + } + + /** + * Create a unique key to identify 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(@NonNull ArrayList apps) { + updateAppList(apps); + } + + @Override + public void onPackageIconChanged() { + } + + @Override + public void onPackageSizeChanged(@NonNull String packageName) { + } + + @Override + public void onAllSizesComputed() { } + + @Override + public void onLauncherInfoChanged() { + } + + @Override + public void onLoadEntriesCompleted() { + updateAppList(); + } + }; +} diff --git a/src/com/android/settings/notification/BundleManageAppsPreferenceController.java b/src/com/android/settings/notification/BundleManageAppsPreferenceController.java new file mode 100644 index 00000000000..6c096598af9 --- /dev/null +++ b/src/com/android/settings/notification/BundleManageAppsPreferenceController.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2025 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.Flags; +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.android.settings.core.BasePreferenceController; + +public class BundleManageAppsPreferenceController extends + BasePreferenceController { + + NotificationBackend mBackend; + + public BundleManageAppsPreferenceController(@NonNull Context context, + @NonNull String preferenceKey) { + super(context, preferenceKey); + mBackend = new NotificationBackend(); + } + + @Override + public int getAvailabilityStatus() { + if (Flags.notificationClassificationUi() && mBackend.isNotificationBundlingSupported()) { + return AVAILABLE; + } + return CONDITIONALLY_UNAVAILABLE; + } +} diff --git a/src/com/android/settings/notification/BundlePreferenceFragment.java b/src/com/android/settings/notification/BundlePreferenceFragment.java index 14de2c26d1c..4a61f9e54af 100644 --- a/src/com/android/settings/notification/BundlePreferenceFragment.java +++ b/src/com/android/settings/notification/BundlePreferenceFragment.java @@ -16,6 +16,11 @@ package com.android.settings.notification; +import static android.service.notification.Adjustment.KEY_SUMMARIZATION; +import static android.service.notification.Adjustment.KEY_TYPE; + +import android.app.Activity; +import android.app.Application; import android.app.settings.SettingsEnums; import android.content.Context; import android.app.Flags; @@ -25,6 +30,7 @@ import androidx.lifecycle.Lifecycle; import com.android.settings.R; import com.android.settings.dashboard.DashboardFragment; import com.android.settings.search.BaseSearchIndexProvider; +import com.android.settingslib.applications.ApplicationsState; import com.android.settingslib.search.SearchIndexable; import org.jetbrains.annotations.NotNull; @@ -49,6 +55,26 @@ public class BundlePreferenceFragment extends DashboardFragment { return "BundlePreferenceFragment"; } + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (use(AdjustmentExcludedAppsPreferenceController.class) != null) { + final Activity activity = getActivity(); + Application app = null; + ApplicationsState appState = null; + if (activity != null) { + app = activity.getApplication(); + } else { + app = null; + } + if (app != null) { + appState = ApplicationsState.getInstance(app); + } + use(AdjustmentExcludedAppsPreferenceController.class).onAttach( + appState, this, new NotificationBackend(), KEY_TYPE); + } + } + public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER = new BaseSearchIndexProvider(R.xml.bundle_notifications_settings) { diff --git a/src/com/android/settings/notification/SummarizationManageAppsPreferenceController.java b/src/com/android/settings/notification/SummarizationManageAppsPreferenceController.java new file mode 100644 index 00000000000..0a19c020fb5 --- /dev/null +++ b/src/com/android/settings/notification/SummarizationManageAppsPreferenceController.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2025 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.Flags; +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.android.settings.core.BasePreferenceController; + +public class SummarizationManageAppsPreferenceController extends + BasePreferenceController { + + NotificationBackend mBackend; + + public SummarizationManageAppsPreferenceController(@NonNull Context context, + @NonNull String preferenceKey) { + super(context, preferenceKey); + mBackend = new NotificationBackend(); + } + + @Override + public int getAvailabilityStatus() { + if ((Flags.nmSummarization() || Flags.nmSummarizationUi()) + && mBackend.isNotificationSummarizationSupported()) { + return AVAILABLE; + } + return CONDITIONALLY_UNAVAILABLE; + } +} diff --git a/src/com/android/settings/notification/SummarizationPreferenceFragment.java b/src/com/android/settings/notification/SummarizationPreferenceFragment.java index 1c8f5e3059d..893383a3b8c 100644 --- a/src/com/android/settings/notification/SummarizationPreferenceFragment.java +++ b/src/com/android/settings/notification/SummarizationPreferenceFragment.java @@ -16,13 +16,19 @@ package com.android.settings.notification; +import static android.service.notification.Adjustment.KEY_SUMMARIZATION; + +import android.app.Activity; +import android.app.Application; import android.app.Flags; import android.app.settings.SettingsEnums; import android.content.Context; import com.android.settings.R; import com.android.settings.dashboard.DashboardFragment; +import com.android.settings.notification.app.HeaderPreferenceController; import com.android.settings.search.BaseSearchIndexProvider; +import com.android.settingslib.applications.ApplicationsState; import com.android.settingslib.search.SearchIndexable; /** @@ -45,6 +51,26 @@ public class SummarizationPreferenceFragment extends DashboardFragment { return "SummarizationPreferenceFragment"; } + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (use(AdjustmentExcludedAppsPreferenceController.class) != null) { + final Activity activity = getActivity(); + Application app = null; + ApplicationsState appState = null; + if (activity != null) { + app = activity.getApplication(); + } else { + app = null; + } + if (app != null) { + appState = ApplicationsState.getInstance(app); + } + use(AdjustmentExcludedAppsPreferenceController.class).onAttach( + appState, this, new NotificationBackend(), KEY_SUMMARIZATION); + } + } + public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER = new BaseSearchIndexProvider(R.xml.summarization_notifications_settings) { diff --git a/src/com/android/settings/spa/SettingsSpaEnvironment.kt b/src/com/android/settings/spa/SettingsSpaEnvironment.kt index 7702db6bcde..f78ebd2b3fd 100644 --- a/src/com/android/settings/spa/SettingsSpaEnvironment.kt +++ b/src/com/android/settings/spa/SettingsSpaEnvironment.kt @@ -45,7 +45,6 @@ import com.android.settings.spa.app.specialaccess.WifiControlAppListProvider import com.android.settings.spa.app.specialaccess.WriteSystemPreferencesAppListProvider import com.android.settings.spa.app.storage.StorageAppListPageProvider import com.android.settings.spa.core.instrumentation.SpaLogMetricsProvider -import com.android.settings.spa.core.instrumentation.SpaLogProvider import com.android.settings.spa.development.UsageStatsPageProvider import com.android.settings.spa.development.compat.PlatformCompatAppListPageProvider import com.android.settings.spa.home.HomePageProvider @@ -106,7 +105,9 @@ open class SettingsSpaEnvironment(context: Context) : SpaEnvironment(context) { AppInfoSettingsProvider, SpecialAppAccessPageProvider, NotificationMainPageProvider, - AppListNotificationsPageProvider, + AppListNotificationsPageProvider.AllApps, + AppListNotificationsPageProvider.ExcludeClassification, + AppListNotificationsPageProvider.ExcludeSummarization, SystemMainPageProvider, LanguageAndInputPageProvider, AppLanguagesPageProvider, diff --git a/src/com/android/settings/spa/notification/AppListNotifications.kt b/src/com/android/settings/spa/notification/AppListNotifications.kt index 00e439495a4..7aa46206f4d 100644 --- a/src/com/android/settings/spa/notification/AppListNotifications.kt +++ b/src/com/android/settings/spa/notification/AppListNotifications.kt @@ -17,34 +17,72 @@ package com.android.settings.spa.notification import android.os.Bundle +import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.android.settings.R import com.android.settingslib.spa.framework.common.SettingsPageProvider import com.android.settingslib.spa.framework.compose.navigator -import com.android.settingslib.spa.framework.compose.rememberContext import com.android.settingslib.spa.widget.preference.Preference import com.android.settingslib.spa.widget.preference.PreferenceModel +import com.android.settingslib.spaprivileged.template.app.AppList +import com.android.settingslib.spaprivileged.template.app.AppListInput import com.android.settingslib.spaprivileged.template.app.AppListPage -object AppListNotificationsPageProvider : SettingsPageProvider { - override val name = "AppListNotifications" - +sealed class AppListNotificationsPageProvider(private val type: ListType) : SettingsPageProvider { @Composable override fun Page(arguments: Bundle?) { - AppListPage( - title = stringResource(R.string.app_notifications_title), - listModel = rememberContext(::AppNotificationsListModel), - ) + NotificationsAppListPage(type) } - @Composable - fun EntryItem() { - val summary = stringResource(R.string.app_notification_field_summary) - Preference(object : PreferenceModel { - override val title = stringResource(R.string.app_notifications_title) - override val summary = { summary } - override val onClick = navigator(name) - }) + object AllApps : AppListNotificationsPageProvider(ListType.Apps) { + override val name = "AppListNotifications" + + @Composable + fun EntryItem() { + val summary = stringResource(R.string.app_notification_field_summary) + Preference(object : PreferenceModel { + override val title = stringResource(ListType.Apps.titleResource) + override val summary = { summary } + override val onClick = navigator(name) + }) + } + } + + object ExcludeSummarization : AppListNotificationsPageProvider(ListType.ExcludeSummarization) { + override val name = "NotificationsExcludeSummarization" + } + + object ExcludeClassification : AppListNotificationsPageProvider(ListType.ExcludeClassification) { + override val name = "NotificationsExcludeClassification" } } + +@Composable +fun NotificationsAppListPage( + type: ListType, + appList: @Composable AppListInput.() -> Unit = { AppList() } +) { + val context = LocalContext.current + AppListPage( + title = stringResource(type.titleResource), + listModel = remember(context) { AppNotificationsListModel(context, type) }, + appList = appList, + ) +} + +sealed class ListType( + @StringRes val titleResource: Int +) { + object Apps : ListType( + titleResource = R.string.app_notifications_title, + ) + object ExcludeSummarization : ListType( + titleResource = R.string.notification_summarization_manage_excluded_apps_title, + ) + object ExcludeClassification : ListType( + titleResource = R.string.notification_bundle_manage_excluded_apps_title, + ) +} \ No newline at end of file diff --git a/src/com/android/settings/spa/notification/AppNotificationController.kt b/src/com/android/settings/spa/notification/AppNotificationController.kt index 1ce72e01f72..fd35427d5c2 100644 --- a/src/com/android/settings/spa/notification/AppNotificationController.kt +++ b/src/com/android/settings/spa/notification/AppNotificationController.kt @@ -17,12 +17,16 @@ package com.android.settings.spa.notification import android.content.pm.ApplicationInfo +import android.service.notification.Adjustment import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import com.android.settings.spa.app.storage.StorageAppListModel +import com.android.settings.spa.app.storage.StorageType class AppNotificationController( private val repository: AppNotificationRepository, private val app: ApplicationInfo, + private val listType: ListType, ) { val isEnabled: LiveData get() = _isEnabled @@ -47,4 +51,62 @@ class AppNotificationController( postValue(it) } } + + val isAllowed: LiveData + get() = _isAllowed + + fun getAllowed() = _isAllowed.get() + + fun setAllowed(enabled: Boolean) { + when (listType) { + ListType.ExcludeSummarization -> { + if (repository.setAdjustmentSupportedForPackage( + app, Adjustment.KEY_SUMMARIZATION, enabled)) { + _isAllowed.postValue(enabled) + } + } + ListType.ExcludeClassification -> { + if (repository.setAdjustmentSupportedForPackage( + app, Adjustment.KEY_TYPE, enabled)) { + _isAllowed.postValue(enabled) + } + } + else -> {} + } + } + + private val _isAllowed = object : MutableLiveData() { + override fun onActive() { + when (listType) { + ListType.ExcludeSummarization -> { + postValue(repository.isAdjustmentSupportedForPackage( + app, Adjustment.KEY_SUMMARIZATION)) + } + ListType.ExcludeClassification -> { + postValue(repository.isAdjustmentSupportedForPackage( + app, Adjustment.KEY_TYPE)) + } + else -> {} + } + } + + override fun onInactive() { + } + + fun get(): Boolean = when (listType) { + ListType.ExcludeSummarization -> { + value ?: repository.isAdjustmentSupportedForPackage( + app, Adjustment.KEY_SUMMARIZATION).also { + postValue(it) + } + } + ListType.ExcludeClassification -> { + value ?: repository.isAdjustmentSupportedForPackage( + app, Adjustment.KEY_TYPE).also { + postValue(it) + } + } + else -> false + } + } } diff --git a/src/com/android/settings/spa/notification/AppNotificationRepository.kt b/src/com/android/settings/spa/notification/AppNotificationRepository.kt index d0e700a9e34..fddbf1aae04 100644 --- a/src/com/android/settings/spa/notification/AppNotificationRepository.kt +++ b/src/com/android/settings/spa/notification/AppNotificationRepository.kt @@ -126,6 +126,20 @@ class AppNotificationRepository( } } + fun isAdjustmentSupportedForPackage(app: ApplicationInfo, key: String): Boolean = + notificationManager.isAdjustmentSupportedForPackage(key, app.packageName) + + fun setAdjustmentSupportedForPackage(app: ApplicationInfo, key: String, enabled: Boolean): + Boolean { + return try { + notificationManager.setAdjustmentSupportedForPackage(key, app.packageName, enabled) + true + } catch (e: Exception) { + Log.w(TAG, "Error calling INotificationManager", e) + false + } + } + fun isUserUnlocked(user: Int): Boolean { return try { userManager.isUserUnlocked(user) diff --git a/src/com/android/settings/spa/notification/AppNotificationsListModel.kt b/src/com/android/settings/spa/notification/AppNotificationsListModel.kt index 8a534c8b14d..d979918dac4 100644 --- a/src/com/android/settings/spa/notification/AppNotificationsListModel.kt +++ b/src/com/android/settings/spa/notification/AppNotificationsListModel.kt @@ -36,6 +36,7 @@ import com.android.settingslib.spaprivileged.model.app.AppListModel import com.android.settingslib.spaprivileged.model.app.AppRecord import com.android.settingslib.spaprivileged.model.app.userId import com.android.settingslib.spaprivileged.template.app.AppListItemModel +import com.android.settingslib.spaprivileged.template.app.AppListSwitchItem import com.android.settingslib.spaprivileged.template.app.AppListTwoTargetSwitchItem import com.android.settingslib.utils.StringUtil import kotlinx.coroutines.Dispatchers @@ -52,6 +53,7 @@ data class AppNotificationsRecord( class AppNotificationsListModel( private val context: Context, + private val listType: ListType ) : AppListModel { private val repository = AppNotificationRepository(context) private val now = System.currentTimeMillis() @@ -64,7 +66,7 @@ class AppNotificationsListModel( AppNotificationsRecord( app = app, sentState = usageEvents[app.packageName], - controller = AppNotificationController(repository, app), + controller = AppNotificationController(repository, app, listType), ) } } @@ -129,17 +131,35 @@ class AppNotificationsListModel( @Composable override fun AppListItemModel.AppItem() { - val changeable by produceState(initialValue = false) { - withContext(Dispatchers.Default) { - value = repository.isChangeable(record.app) + when (listType) { + ListType.ExcludeSummarization -> { + AppListSwitchItem( + checked = record.controller.isAllowed.observeAsCallback(), + changeable = { true }, + onCheckedChange = record.controller::setAllowed, + ) + } + ListType.ExcludeClassification -> { + AppListSwitchItem( + checked = record.controller.isAllowed.observeAsCallback(), + changeable = { true }, + onCheckedChange = record.controller::setAllowed, + ) + } + else -> { + val changeable by produceState(initialValue = false) { + withContext(Dispatchers.Default) { + value = repository.isChangeable(record.app) + } + } + AppListTwoTargetSwitchItem( + onClick = { navigateToAppNotificationSettings(app = record.app) }, + checked = record.controller.isEnabled.observeAsCallback(), + changeable = { changeable }, + onCheckedChange = record.controller::setEnabled, + ) } } - AppListTwoTargetSwitchItem( - onClick = { navigateToAppNotificationSettings(app = record.app) }, - checked = record.controller.isEnabled.observeAsCallback(), - changeable = { changeable }, - onCheckedChange = record.controller::setEnabled, - ) } private fun navigateToAppNotificationSettings(app: ApplicationInfo) { diff --git a/src/com/android/settings/spa/notification/NotificationMain.kt b/src/com/android/settings/spa/notification/NotificationMain.kt index b3c7a55465c..6859aa916f6 100644 --- a/src/com/android/settings/spa/notification/NotificationMain.kt +++ b/src/com/android/settings/spa/notification/NotificationMain.kt @@ -41,7 +41,7 @@ object NotificationMainPageProvider : SettingsPageProvider { @Composable override fun Page(arguments: Bundle?) { RegularScaffold(title = getTitle(arguments)) { - AppListNotificationsPageProvider.EntryItem() + AppListNotificationsPageProvider.AllApps.EntryItem() } } diff --git a/tests/robotests/src/com/android/settings/notification/AdjustmentExcludedAppsPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/AdjustmentExcludedAppsPreferenceControllerTest.java new file mode 100644 index 00000000000..ec68a7a14d4 --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/AdjustmentExcludedAppsPreferenceControllerTest.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2025 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 android.service.notification.Adjustment.KEY_SUMMARIZATION; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.app.Flags; +import android.app.INotificationManager; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; + +import androidx.fragment.app.Fragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceManager; +import androidx.preference.PreferenceScreen; + +import com.android.settingslib.applications.ApplicationsState; + +import org.junit.Before; +import org.junit.Rule; +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 java.util.ArrayList; +import java.util.List; + +@RunWith(RobolectricTestRunner.class) +@EnableFlags({Flags.FLAG_NM_SUMMARIZATION_UI, Flags.FLAG_NM_SUMMARIZATION, + Flags.FLAG_NOTIFICATION_CLASSIFICATION_UI}) +public class AdjustmentExcludedAppsPreferenceControllerTest { + + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + + @Mock + private NotificationBackend mBackend; + @Mock + private ApplicationsState mApplicationState; + private AdjustmentExcludedAppsPreferenceController mController; + private Context mContext; + @Mock + INotificationManager mInm; + + @Before + public void setup() { + MockitoAnnotations.initMocks(this); + mContext = RuntimeEnvironment.application; + + mController = new AdjustmentExcludedAppsPreferenceController(mContext, "key"); + mController.onAttach(null, mock(Fragment.class), mBackend, KEY_SUMMARIZATION); + PreferenceScreen screen = new PreferenceManager(mContext).createPreferenceScreen(mContext); + mController.mPreferenceCategory = new PreferenceCategory(mContext); + screen.addPreference(mController.mPreferenceCategory); + + mController.mApplicationsState = mApplicationState; + mController.mPrefContext = mContext; + } + + @Test + public void testIsAvailable() { + when(mBackend.isNotificationBundlingSupported()).thenReturn(true); + when(mBackend.isNotificationSummarizationSupported()).thenReturn(true); + assertThat(mController.isAvailable()).isTrue(); + } + + @Test + public void isAvailable_flagEnabledNasDoesNotSupport_shouldReturnFalse() throws Exception { + when(mInm.getUnsupportedAdjustmentTypes()).thenReturn(List.of(KEY_SUMMARIZATION)); + assertThat(mController.isAvailable()).isFalse(); + } + + @Test + public void testUpdateAppList() throws Exception { + when(mBackend.getAdjustmentDeniedPackages(KEY_SUMMARIZATION)).thenReturn( + new String[] {"cannot", "cannot2"}); + + // GIVEN there are four apps, and two have KEY_SUMMARIZATION off + ApplicationsState.AppEntry canSummarize = + mock(ApplicationsState.AppEntry.class); + canSummarize.info = new ApplicationInfo(); + canSummarize.info.packageName = "canSummarize"; + canSummarize.info.uid = 0; + + ApplicationsState.AppEntry canSummarize2 = mock(ApplicationsState.AppEntry.class); + canSummarize2.info = new ApplicationInfo(); + canSummarize2.info.packageName = "canSummarizeTwo"; + canSummarize2.info.uid = 0; + + ApplicationsState.AppEntry cannot = + mock(ApplicationsState.AppEntry.class); + cannot.info = new ApplicationInfo(); + cannot.info.packageName = "cannot"; + cannot.info.uid = 0; + + ApplicationsState.AppEntry cannot2 = + mock(ApplicationsState.AppEntry.class); + cannot2.info = new ApplicationInfo(); + cannot2.info.packageName = "cannot2"; + cannot2.info.uid = 0; + + List appEntries = new ArrayList<>(); + appEntries.add(canSummarize); + appEntries.add(canSummarize2); + appEntries.add(cannot); + appEntries.add(cannot2); + + // WHEN the controller updates the app list with the app entries + mController.updateAppList(appEntries); + + // THEN only the 'cannot' entries make it to the app list + assertThat(mController.mPreferenceCategory.getPreferenceCount()).isEqualTo(2); + assertThat((Preference) mController.mPreferenceCategory.findPreference( + AdjustmentExcludedAppsPreferenceController.getKey( + cannot.info.packageName,cannot.info.uid))).isNotNull(); + assertThat((Preference) mController.mPreferenceCategory.findPreference( + AdjustmentExcludedAppsPreferenceController.getKey( + cannot2.info.packageName,cannot2.info.uid))).isNotNull(); + } + + @Test + public void testUpdateAppList_nullApps() { + mController.updateAppList(null); + assertThat(mController.mPreferenceCategory.getPreferenceCount()).isEqualTo(0); + } +} diff --git a/tests/robotests/src/com/android/settings/notification/BundleManageAppsPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/BundleManageAppsPreferenceControllerTest.java new file mode 100644 index 00000000000..7f595b5c7e0 --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/BundleManageAppsPreferenceControllerTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2025 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 android.service.notification.Adjustment.KEY_SUMMARIZATION; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.when; + +import android.app.Flags; +import android.app.INotificationManager; +import android.content.Context; +import android.platform.test.flag.junit.SetFlagsRule; +import android.service.notification.Adjustment; + +import org.junit.Before; +import org.junit.Rule; +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 java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class BundleManageAppsPreferenceControllerTest { + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + + private static final String PREFERENCE_KEY = "preference_key"; + + private Context mContext; + BundleManageAppsPreferenceController mController; + @Mock + INotificationManager mInm; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = RuntimeEnvironment.application; + mSetFlagsRule.enableFlags(Flags.FLAG_NOTIFICATION_CLASSIFICATION_UI); + mController = new BundleManageAppsPreferenceController(mContext, PREFERENCE_KEY); + mController.mBackend.setNm(mInm); + } + + @Test + public void isAvailable_flagEnabledNasSupports_shouldReturnTrue() { + assertThat(mController.isAvailable()).isTrue(); + } + + @Test + public void isAvailable_flagEnabledNasDoesNotSupport_shouldReturnFalse() throws Exception { + when(mInm.getUnsupportedAdjustmentTypes()).thenReturn(List.of(Adjustment.KEY_TYPE)); + assertThat(mController.isAvailable()).isFalse(); + } + + @Test + public void isAvailable_flagDisabledNasSupports_shouldReturnFalse() { + mSetFlagsRule.disableFlags(Flags.FLAG_NOTIFICATION_CLASSIFICATION_UI); + assertThat(mController.isAvailable()).isFalse(); + } +} diff --git a/tests/robotests/src/com/android/settings/notification/SummarizationManageAppsPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/SummarizationManageAppsPreferenceControllerTest.java new file mode 100644 index 00000000000..b6cedb86585 --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/SummarizationManageAppsPreferenceControllerTest.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2025 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 android.service.notification.Adjustment.KEY_IMPORTANCE; +import static android.service.notification.Adjustment.KEY_SUMMARIZATION; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import android.app.Flags; +import android.app.INotificationManager; +import android.content.Context; +import android.platform.test.flag.junit.SetFlagsRule; + +import org.junit.Before; +import org.junit.Rule; +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 java.util.List; + +@RunWith(RobolectricTestRunner.class) +public class SummarizationManageAppsPreferenceControllerTest { + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + + private static final String PREFERENCE_KEY = "preference_key"; + + private Context mContext; + SummarizationManageAppsPreferenceController mController; + @Mock + INotificationManager mInm; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = RuntimeEnvironment.application; + mSetFlagsRule.enableFlags(Flags.FLAG_NM_SUMMARIZATION, Flags.FLAG_NM_SUMMARIZATION_UI); + mController = new SummarizationManageAppsPreferenceController(mContext, PREFERENCE_KEY); + mController.mBackend.setNm(mInm); + } + + @Test + public void isAvailable_flagEnabledNasSupports_shouldReturnTrue() { + assertThat(mController.isAvailable()).isTrue(); + } + + @Test + public void isAvailable_flagEnabledNasDoesNotSupport_shouldReturnFalse() throws Exception { + when(mInm.getUnsupportedAdjustmentTypes()).thenReturn(List.of(KEY_SUMMARIZATION)); + assertThat(mController.isAvailable()).isFalse(); + } + + @Test + public void isAvailable_flagDisabledNasSupports_shouldReturnFalse() { + mSetFlagsRule.disableFlags(Flags.FLAG_NM_SUMMARIZATION); + mSetFlagsRule.disableFlags(Flags.FLAG_NM_SUMMARIZATION_UI); + assertThat(mController.isAvailable()).isFalse(); + } +}