Add ability to exclude apps from adjustments

Specifically bundling and summarization

Test: BundleManageAppsPreferenceControllerTest
Test: AdjustmentExcludedAppsPreferenceControllerTest
Test: SummarizationManageAppsPreferenceControllerTest
Flag: android.app.nm_summarization
Flag: android.app.notification_classification_ui
Bug: 390415383
Bug: 377697346
Change-Id: Ica4b77212f4660624bfe12be7e6c9c584cd2c812
This commit is contained in:
Julia Reynolds
2025-01-28 15:16:09 -05:00
parent 17dc54c62c
commit 0762b81bcf
19 changed files with 890 additions and 30 deletions

View File

@@ -56,4 +56,27 @@
android:key="recs" android:key="recs"
android:title="@*android:string/recs_notification_channel_label" android:title="@*android:string/recs_notification_channel_label"
settings:controller="com.android.settings.notification.BundleTypePreferenceController"/> settings:controller="com.android.settings.notification.BundleTypePreferenceController"/>
<PreferenceCategory
android:key="notification_bundle_excluded_apps_list"
android:title="@string/notification_excluded_apps"
settings:controller="com.android.settings.notification.AdjustmentExcludedAppsPreferenceController">
<com.android.settingslib.widget.TopIntroPreference
android:key="excluded_description"
android:title="@string/notification_bundle_excluded_description"/>
<!-- apps are added here -->
<Preference
android:key="notification_bundle_manage_apps"
android:order="1000"
android:icon="@drawable/ic_chevron_right_24dp"
android:title="@string/notification_summarization_manage_excluded_apps"
settings:controller="com.android.settings.notification.BundleManageAppsPreferenceController"
android:fragment="com.android.settings.applications.manageapplications.ManageApplications">
<extra
android:name="classname"
android:value="com.android.settings.Settings$NotificationExcludeClassificationActivity"/>
</Preference>
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>

View File

@@ -36,4 +36,29 @@
android:key="global_pref" android:key="global_pref"
android:title="@string/notification_summarization_main_control_title" android:title="@string/notification_summarization_main_control_title"
settings:controller="com.android.settings.notification.SummarizationGlobalPreferenceController" /> settings:controller="com.android.settings.notification.SummarizationGlobalPreferenceController" />
<PreferenceCategory
android:key="notification_summarization_excluded_apps_list"
android:title="@string/notification_excluded_apps"
settings:controller="com.android.settings.notification.AdjustmentExcludedAppsPreferenceController">
<com.android.settingslib.widget.TopIntroPreference
android:key="excluded_description"
android:title="@string/notification_summarization_excluded_description"/>
<!-- apps are added here -->
<Preference
android:key="notification_summarization_manage_apps"
android:order="1000"
android:icon="@drawable/ic_chevron_right_24dp"
android:title="@string/notification_summarization_manage_excluded_apps"
settings:controller="com.android.settings.notification.SummarizationManageAppsPreferenceController"
android:fragment="com.android.settings.applications.manageapplications.ManageApplications">
<extra
android:name="classname"
android:value="com.android.settings.Settings$NotificationExcludeSummarizationActivity"/>
</Preference>
</PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>

View File

@@ -358,6 +358,8 @@ public class Settings extends SettingsActivity {
public static class AppBubbleNotificationSettingsActivity extends SettingsActivity { /* empty */ } public static class AppBubbleNotificationSettingsActivity extends SettingsActivity { /* empty */ }
public static class NotificationAssistantSettingsActivity extends SettingsActivity{ /* empty */ } public static class NotificationAssistantSettingsActivity extends SettingsActivity{ /* empty */ }
public static class NotificationAppListActivity 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 */ /** Activity to manage Cloned Apps page */
public static class ClonedAppsListActivity extends SettingsActivity { /* empty */ } public static class ClonedAppsListActivity extends SettingsActivity { /* empty */ }
/** Activity to manage Aspect Ratio app list page */ /** Activity to manage Aspect Ratio app list page */

View File

@@ -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_NFC_TAG_APPS = 18;
public static final int LIST_TYPE_TURN_SCREEN_ON = 19; 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_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. // List types that should show instant apps.
public static final Set<Integer> LIST_TYPES_WITH_INSTANT = new ArraySet<>(Arrays.asList( public static final Set<Integer> LIST_TYPES_WITH_INSTANT = new ArraySet<>(Arrays.asList(

View File

@@ -31,6 +31,8 @@ import com.android.settings.Settings.ManageExternalSourcesActivity
import com.android.settings.Settings.ManageExternalStorageActivity import com.android.settings.Settings.ManageExternalStorageActivity
import com.android.settings.Settings.MediaManagementAppsActivity import com.android.settings.Settings.MediaManagementAppsActivity
import com.android.settings.Settings.NotificationAppListActivity 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.NotificationReviewPermissionsActivity
import com.android.settings.Settings.OverlaySettingsActivity import com.android.settings.Settings.OverlaySettingsActivity
import com.android.settings.Settings.StorageUseActivity 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_USER_ASPECT_RATIO_APPS
import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_BATTERY_OPTIMIZATION 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_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_GAMES
import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_HIGH_POWER import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_HIGH_POWER
import com.android.settings.applications.manageapplications.ManageApplications.LIST_TYPE_LONG_BACKGROUND_TASKS 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, ChangeNfcTagAppsActivity::class to LIST_TYPE_NFC_TAG_APPS,
TurnScreenOnSettingsActivity::class to LIST_TYPE_TURN_SCREEN_ON, TurnScreenOnSettingsActivity::class to LIST_TYPE_TURN_SCREEN_ON,
UserAspectRatioAppListActivity::class to LIST_TYPE_USER_ASPECT_RATIO_APPS, 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 @JvmField
@@ -117,7 +124,7 @@ object ManageApplicationsUtil {
LIST_TYPE_MEDIA_MANAGEMENT_APPS -> MediaManagementAppsAppListProvider.getAppListRoute() LIST_TYPE_MEDIA_MANAGEMENT_APPS -> MediaManagementAppsAppListProvider.getAppListRoute()
LIST_TYPE_ALARMS_AND_REMINDERS -> AlarmsAndRemindersAppListProvider.getAppListRoute() LIST_TYPE_ALARMS_AND_REMINDERS -> AlarmsAndRemindersAppListProvider.getAppListRoute()
LIST_TYPE_WIFI_ACCESS -> WifiControlAppListProvider.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_APPS_LOCALE -> AppLanguagesPageProvider.name
LIST_TYPE_MAIN -> AllAppListPageProvider.name LIST_TYPE_MAIN -> AllAppListPageProvider.name
LIST_TYPE_NFC_TAG_APPS -> NfcTagAppsSettingsProvider.getAppListRoute() LIST_TYPE_NFC_TAG_APPS -> NfcTagAppsSettingsProvider.getAppListRoute()
@@ -128,6 +135,8 @@ object ManageApplicationsUtil {
//LIST_TYPE_STORAGE -> StorageAppListPageProvider.Apps.name //LIST_TYPE_STORAGE -> StorageAppListPageProvider.Apps.name
//LIST_TYPE_GAMES -> StorageAppListPageProvider.Games.name //LIST_TYPE_GAMES -> StorageAppListPageProvider.Games.name
LIST_TYPE_BATTERY_OPTIMIZATION -> BatteryOptimizationModeAppListPageProvider.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 else -> null
} }
} }

View File

@@ -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<ApplicationsState.AppEntry> apps) {
if (mPreferenceCategory == null || apps == null) {
return;
}
List<String> 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<ApplicationsState.AppEntry> 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();
}
};
}

View File

@@ -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;
}
}

View File

@@ -16,6 +16,11 @@
package com.android.settings.notification; 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.app.settings.SettingsEnums;
import android.content.Context; import android.content.Context;
import android.app.Flags; import android.app.Flags;
@@ -25,6 +30,7 @@ import androidx.lifecycle.Lifecycle;
import com.android.settings.R; import com.android.settings.R;
import com.android.settings.dashboard.DashboardFragment; import com.android.settings.dashboard.DashboardFragment;
import com.android.settings.search.BaseSearchIndexProvider; import com.android.settings.search.BaseSearchIndexProvider;
import com.android.settingslib.applications.ApplicationsState;
import com.android.settingslib.search.SearchIndexable; import com.android.settingslib.search.SearchIndexable;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@@ -49,6 +55,26 @@ public class BundlePreferenceFragment extends DashboardFragment {
return "BundlePreferenceFragment"; 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 = public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER =
new BaseSearchIndexProvider(R.xml.bundle_notifications_settings) { new BaseSearchIndexProvider(R.xml.bundle_notifications_settings) {

View File

@@ -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;
}
}

View File

@@ -16,13 +16,19 @@
package com.android.settings.notification; 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.Flags;
import android.app.settings.SettingsEnums; import android.app.settings.SettingsEnums;
import android.content.Context; import android.content.Context;
import com.android.settings.R; import com.android.settings.R;
import com.android.settings.dashboard.DashboardFragment; import com.android.settings.dashboard.DashboardFragment;
import com.android.settings.notification.app.HeaderPreferenceController;
import com.android.settings.search.BaseSearchIndexProvider; import com.android.settings.search.BaseSearchIndexProvider;
import com.android.settingslib.applications.ApplicationsState;
import com.android.settingslib.search.SearchIndexable; import com.android.settingslib.search.SearchIndexable;
/** /**
@@ -45,6 +51,26 @@ public class SummarizationPreferenceFragment extends DashboardFragment {
return "SummarizationPreferenceFragment"; 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 = public static final BaseSearchIndexProvider SEARCH_INDEX_DATA_PROVIDER =
new BaseSearchIndexProvider(R.xml.summarization_notifications_settings) { new BaseSearchIndexProvider(R.xml.summarization_notifications_settings) {

View File

@@ -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.specialaccess.WriteSystemPreferencesAppListProvider
import com.android.settings.spa.app.storage.StorageAppListPageProvider import com.android.settings.spa.app.storage.StorageAppListPageProvider
import com.android.settings.spa.core.instrumentation.SpaLogMetricsProvider 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.UsageStatsPageProvider
import com.android.settings.spa.development.compat.PlatformCompatAppListPageProvider import com.android.settings.spa.development.compat.PlatformCompatAppListPageProvider
import com.android.settings.spa.home.HomePageProvider import com.android.settings.spa.home.HomePageProvider
@@ -106,7 +105,9 @@ open class SettingsSpaEnvironment(context: Context) : SpaEnvironment(context) {
AppInfoSettingsProvider, AppInfoSettingsProvider,
SpecialAppAccessPageProvider, SpecialAppAccessPageProvider,
NotificationMainPageProvider, NotificationMainPageProvider,
AppListNotificationsPageProvider, AppListNotificationsPageProvider.AllApps,
AppListNotificationsPageProvider.ExcludeClassification,
AppListNotificationsPageProvider.ExcludeSummarization,
SystemMainPageProvider, SystemMainPageProvider,
LanguageAndInputPageProvider, LanguageAndInputPageProvider,
AppLanguagesPageProvider, AppLanguagesPageProvider,

View File

@@ -17,34 +17,72 @@
package com.android.settings.spa.notification package com.android.settings.spa.notification
import android.os.Bundle import android.os.Bundle
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.android.settings.R import com.android.settings.R
import com.android.settingslib.spa.framework.common.SettingsPageProvider import com.android.settingslib.spa.framework.common.SettingsPageProvider
import com.android.settingslib.spa.framework.compose.navigator 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.Preference
import com.android.settingslib.spa.widget.preference.PreferenceModel 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 import com.android.settingslib.spaprivileged.template.app.AppListPage
object AppListNotificationsPageProvider : SettingsPageProvider { sealed class AppListNotificationsPageProvider(private val type: ListType) : SettingsPageProvider {
override val name = "AppListNotifications"
@Composable @Composable
override fun Page(arguments: Bundle?) { override fun Page(arguments: Bundle?) {
AppListPage( NotificationsAppListPage(type)
title = stringResource(R.string.app_notifications_title),
listModel = rememberContext(::AppNotificationsListModel),
)
} }
@Composable object AllApps : AppListNotificationsPageProvider(ListType.Apps) {
fun EntryItem() { override val name = "AppListNotifications"
val summary = stringResource(R.string.app_notification_field_summary)
Preference(object : PreferenceModel { @Composable
override val title = stringResource(R.string.app_notifications_title) fun EntryItem() {
override val summary = { summary } val summary = stringResource(R.string.app_notification_field_summary)
override val onClick = navigator(name) 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<AppNotificationsRecord>.() -> 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,
)
}

View File

@@ -17,12 +17,16 @@
package com.android.settings.spa.notification package com.android.settings.spa.notification
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.service.notification.Adjustment
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.android.settings.spa.app.storage.StorageAppListModel
import com.android.settings.spa.app.storage.StorageType
class AppNotificationController( class AppNotificationController(
private val repository: AppNotificationRepository, private val repository: AppNotificationRepository,
private val app: ApplicationInfo, private val app: ApplicationInfo,
private val listType: ListType,
) { ) {
val isEnabled: LiveData<Boolean> val isEnabled: LiveData<Boolean>
get() = _isEnabled get() = _isEnabled
@@ -47,4 +51,62 @@ class AppNotificationController(
postValue(it) postValue(it)
} }
} }
val isAllowed: LiveData<Boolean>
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<Boolean>() {
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
}
}
} }

View File

@@ -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 { fun isUserUnlocked(user: Int): Boolean {
return try { return try {
userManager.isUserUnlocked(user) userManager.isUserUnlocked(user)

View File

@@ -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.AppRecord
import com.android.settingslib.spaprivileged.model.app.userId import com.android.settingslib.spaprivileged.model.app.userId
import com.android.settingslib.spaprivileged.template.app.AppListItemModel 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.spaprivileged.template.app.AppListTwoTargetSwitchItem
import com.android.settingslib.utils.StringUtil import com.android.settingslib.utils.StringUtil
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -52,6 +53,7 @@ data class AppNotificationsRecord(
class AppNotificationsListModel( class AppNotificationsListModel(
private val context: Context, private val context: Context,
private val listType: ListType
) : AppListModel<AppNotificationsRecord> { ) : AppListModel<AppNotificationsRecord> {
private val repository = AppNotificationRepository(context) private val repository = AppNotificationRepository(context)
private val now = System.currentTimeMillis() private val now = System.currentTimeMillis()
@@ -64,7 +66,7 @@ class AppNotificationsListModel(
AppNotificationsRecord( AppNotificationsRecord(
app = app, app = app,
sentState = usageEvents[app.packageName], sentState = usageEvents[app.packageName],
controller = AppNotificationController(repository, app), controller = AppNotificationController(repository, app, listType),
) )
} }
} }
@@ -129,17 +131,35 @@ class AppNotificationsListModel(
@Composable @Composable
override fun AppListItemModel<AppNotificationsRecord>.AppItem() { override fun AppListItemModel<AppNotificationsRecord>.AppItem() {
val changeable by produceState(initialValue = false) { when (listType) {
withContext(Dispatchers.Default) { ListType.ExcludeSummarization -> {
value = repository.isChangeable(record.app) 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) { private fun navigateToAppNotificationSettings(app: ApplicationInfo) {

View File

@@ -41,7 +41,7 @@ object NotificationMainPageProvider : SettingsPageProvider {
@Composable @Composable
override fun Page(arguments: Bundle?) { override fun Page(arguments: Bundle?) {
RegularScaffold(title = getTitle(arguments)) { RegularScaffold(title = getTitle(arguments)) {
AppListNotificationsPageProvider.EntryItem() AppListNotificationsPageProvider.AllApps.EntryItem()
} }
} }

View File

@@ -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<ApplicationsState.AppEntry> 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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}